Files
minstrel/docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md
T
bvandeusen 65bb4e6dfd docs(m7): spec for #352 playlists CRUD (slice 1 of 3)
Manual playlists only — schema (migration 0014), backend service +
collage generator, /api/playlists* endpoints, /playlists list +
detail pages, AddToPlaylistMenu wired into TrackMenu's reserved slot.

10 locked decisions captured: private-by-default, manual-only,
api-only (no Subsonic), 2×2 collage always (glyph fills missing
cells), soft-mark cascade with denormalized snapshot, full-list
reorder, inline collage gen, composite PK on (playlist_id, position),
denormalized track_count + duration_sec rollups.

Slices 2 (system-generated daily mixes) and 3 (home-page playlists
row) are sibling cycles; per v1=full-product all three ship before
tag.
2026-05-03 00:28:34 -04:00

372 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M7 — Playlists CRUD (slice 1 of #352)
> **Status:** Draft for review · 2026-05-03
>
> **Phase:** M7 task #352 slice 1 of 3. Lays the schema, backend, and `/playlists` UI for **manually-curated** playlists. Slice 2 (system-generated daily mixes) and slice 3 (home-page playlists row) are sibling spec/plan cycles. Per the v1=full-product framing all three ship before tag — none is "deferred."
>
> **Sequencing:** Lands after M7 #372 (track-actions menu, commit `1cf58b1`) which reserved an "Add to playlist…" slot in `<TrackMenu>` for this slice to wire.
## 1. Goal
Operators can create named playlists, add tracks from anywhere in the library, reorder them by drag, edit name/description, mark them public to share with other users on the same Minstrel instance, and delete them. Tracks that vanish from the underlying library (Lidarr re-import, manual removal, file deletion) leave behind an explicit "track unavailable" row in the playlist with the original metadata snapshot — the playlist never silently shrinks.
## 2. Goals and non-goals
### Goals
- Manual playlists only. The owner adds and orders tracks explicitly. No rule-based smart playlists in this slice (or anywhere in v1 — system-generated daily mixes from #352 slice 2 are a different concept that produces same-shape `playlist_tracks` rows).
- Sharing model: **private by default, owner publishes** via `is_public`. Edit / delete / reorder / add / remove tracks are owner-only. Read is owner OR `is_public=true`. Other users see public playlists in browse but cannot mutate them.
- Track-removal cascade: **soft mark with denormalized snapshot.** `playlist_tracks` carries `title`, `artist_name`, `album_title`, `duration_sec` denormalized at time-of-add. When the upstream `tracks` row is deleted, `track_id` becomes `NULL` (FK `ON DELETE SET NULL`); the playlist row stays, with the snapshot visible. UI renders such rows greyed-out + strikethrough; the operator can manually remove or replace.
- Reorder API: full ordered-list rewrite. Drag-and-drop client → `PUT /api/playlists/{id}/tracks` with `{ordered_positions: [...]}` → server rewrites all positions in a single transaction. Idempotent.
- Cover art: server-generated 2×2 collage of the first 4 contributing tracks' album covers. Generated **synchronously inline** on every track add/remove/reorder via Go's `image/jpeg` stdlib. Cached on disk at `data/playlist_covers/{playlist_id}.jpg`; path stored in `playlists.cover_path`. Empty playlists → `cover_path=NULL`, UI renders a FabledSword glyph.
- API surface lives at `/api/playlists*`. **No `/rest/*` Subsonic parity** — aligns with `project_subsonic_legacy.md`. Subsonic clients won't see playlists; that's a separate post-v1 task if/when needed.
- "Add to playlist…" entry in `<TrackMenu>` (the slot M7 #372 reserved) — clicking opens a submenu listing the user's own playlists alphabetically + a "+ New playlist…" option.
- `/playlists` index page (rewrite of the existing placeholder) shows two sections: "Your playlists" (owned, public + private) and "From other users" (others' public playlists). "+ New playlist" CTA in header.
- `/playlists/{id}` detail page: collage + name + description + public/private chip + edit + delete buttons (owner only) + drag-to-reorder track list + per-row remove + LikeButton + the existing `<TrackMenu>` kebab on each row.
- Denormalized rollups (`track_count`, `duration_sec`) on `playlists` so list pages don't aggregate at read time — updated by the service layer on every track-list mutation.
- Empty-state copy on the `/playlists` index when the operator has no playlists yet.
### Non-goals (this slice)
- System-generated playlists (daily "For You", "Songs like X") — slice 2.
- Home-page playlists row — slice 3.
- Smart / rule-based playlists — post-v1; different feature concept.
- Subsonic playlist endpoints — post-v1 if at all.
- Collaborative editing (multiple users editing one playlist) — post-v1.
- Public-playlist forks/copies into the operator's own collection — post-v1.
- Cover-collage refresh when an underlying track's cover changes silently — slice trade-off; the collage stays stale until the next playlist mutation. Rare in practice.
- Per-playlist Subsonic permissions / role gating — out of scope.
- Flutter mirror — separate slice once #356 resumes (per the M7 ordering decision: web first, Flutter mirrors after).
## 3. Architecture
### 3.1 Schema (migration 0014)
```sql
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, -- relative to data/, NULL until first cover
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;
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,
-- denormalized snapshot at time-of-add; survives upstream track removal
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)
);
CREATE INDEX playlist_tracks_track_idx ON playlist_tracks (track_id) WHERE track_id IS NOT NULL;
```
Notes:
- Composite PK on `(playlist_id, position)` is intentional. Reorder rewrites the PK by deleting + reinserting in the same transaction. Acceptable: typical playlists are ≤ a few hundred rows.
- The partial index on `playlist_tracks.track_id` is non-redundant for 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`.
- `playlists.user_id` cascades on user deletion. Acceptable: deleting a user should remove their data.
- `playlists.cover_path` is a relative path (e.g., `playlist_covers/abc.jpg`); resolved against the configured `data_dir` from `config.yaml`.
### 3.2 Backend module (`internal/playlists`)
New package. Exports a `Service` with methods:
- `Create(ctx, userID, name, description, isPublic) (*PlaylistRow, error)`
- `Get(ctx, callerUserID, playlistID) (*PlaylistDetail, error)` — enforces visibility (owner OR `is_public`).
- `List(ctx, callerUserID) ([]PlaylistRow, error)` — owner's all + others' public, ordered by `updated_at DESC`.
- `Update(ctx, userID, playlistID, name?, description?, isPublic?) error` — owner-only.
- `Delete(ctx, userID, playlistID) error` — owner-only. Cascades `playlist_tracks`; deletes the cached cover file from disk.
- `AppendTracks(ctx, userID, playlistID, trackIDs) error` — owner-only. Validates each track exists; populates the denormalized snapshot fields by joining at insert time. Bumps `track_count`, `duration_sec`, `updated_at`. Triggers cover regeneration.
- `RemoveTrack(ctx, userID, playlistID, position) error` — owner-only. Deletes the row at `position`; renumbers all rows with `position > p` to close the gap. Bumps rollups. Triggers cover regeneration.
- `Reorder(ctx, userID, playlistID, orderedPositions []int) error` — owner-only. Validates the input is a permutation of the existing positions. Atomic re-write in a single transaction. Triggers cover regeneration if the first 4 positions changed.
Typed errors: `ErrNotFound`, `ErrForbidden`, `ErrInvalidInput`, `ErrTrackNotFound`.
`internal/playlists/collage.go` exposes `GenerateCollage(ctx, pool, playlistID, dataDir) (path string, err error)` — fetches the first 4 tracks' cover URLs, downloads / reads the local files, composes a 600×600 2×2 grid via `image/jpeg`, writes to `dataDir/playlist_covers/{playlistID}.jpg`, returns the relative path. The Service calls this synchronously after every mutating method that affects ordering or composition.
**Layout is always 2×2** regardless of track count. Each cell is 300×300, output is 600×600. Missing cells (when the playlist has < 4 tracks, or when a contributing track's `cover_url` is empty / unavailable) are filled with the FabledSword `album-fallback.svg` rasterized to 300×300. This means a 1-track playlist shows the track's cover top-left and the glyph in the other 3 cells; a 3-track playlist fills cells 1-3 with covers and cell 4 with the glyph. Consistent rendering, no special-casing per count.
### 3.3 API handlers (`internal/api/playlists.go`)
Routes registered inside `r.Route("/api", ...)``authed.Group(...)`:
- `GET /api/playlists`
- `POST /api/playlists`
- `GET /api/playlists/{id}`
- `PATCH /api/playlists/{id}`
- `DELETE /api/playlists/{id}`
- `POST /api/playlists/{id}/tracks` (append)
- `PUT /api/playlists/{id}/tracks` (reorder; full-list)
- `DELETE /api/playlists/{id}/tracks/{position}` (remove one)
- `GET /api/playlists/{id}/cover` (serve the cached collage, 404 → glyph fallback in UI)
Owner-only routes use `s.RequireOwner(playlist.user_id)` per-handler check (extension of the existing auth helpers). Read routes apply visibility gating in the service layer.
### 3.4 Frontend
**Files (create):**
- `web/src/lib/api/playlists.ts``listPlaylists`, `getPlaylist`, `createPlaylist`, `updatePlaylist`, `deletePlaylist`, `appendTracks`, `reorderPlaylist`, `removeTrack`. TanStack Query factories: `createPlaylistsQuery`, `createPlaylistQuery(id)`.
- `web/src/lib/api/playlists.test.ts`
- `web/src/lib/components/PlaylistCard.svelte` — collage + name + track_count + (creator name when not owned). Used in `/playlists` lists.
- `web/src/lib/components/PlaylistCard.test.ts`
- `web/src/lib/components/AddToPlaylistMenu.svelte` — submenu for `<TrackMenu>`'s "Add to playlist…" slot. Lists the operator's own playlists alphabetically. "+ New playlist…" at the bottom opens a small inline dialog (name field + Create button) that adds the track to the new playlist.
- `web/src/lib/components/AddToPlaylistMenu.test.ts`
- `web/src/lib/components/PlaylistTrackRow.svelte` — variant of `<TrackRow>` for playlist detail. Adds drag handle (owner only), strike-through styling for `track_id=NULL` rows, per-row "Remove" via `<TrackMenu>` extension.
- `web/src/lib/components/PlaylistTrackRow.test.ts`
- `web/src/routes/playlists/+page.svelte` — full rewrite of the placeholder. List + create CTA.
- `web/src/routes/playlists/[id]/+page.svelte` — detail with header + drag-reorderable track list.
- `web/src/routes/playlists/playlists.test.ts`
- `web/src/routes/playlists/[id]/playlist.test.ts`
**Files (modify):**
- `web/src/lib/components/TrackMenu.svelte` — replace the disabled "Add to playlist…" placeholder with `<AddToPlaylistMenu>` mounted as a submenu. Drop the `disabled` flag and "Coming with playlists" tooltip.
- `web/src/lib/components/TrackMenu.test.ts` — update assertion: "Add to playlist…" is now enabled.
- `web/src/lib/api/queries.ts` — add `qk.playlists()`, `qk.playlist(id)`.
- `web/src/lib/components/Shell.svelte` — Playlists already in the nav since the M6a polish; verify the link points at `/playlists`.
### 3.5 Drag-to-reorder
- Use the browser's native HTML5 drag-and-drop (`draggable`, `ondragstart`, `ondragover`, `ondrop`). No third-party drag library.
- Visual: drag handle on the leftmost column (a Lucide `GripVertical` icon, owner only). Dragging shows a ghost row; drop fires the reorder.
- On drop, the client builds the new ordered position array and calls `PUT /api/playlists/{id}/tracks`. Optimistic local update with rollback on error (toast via `copyForCode`).
### 3.6 "Add to playlist" flow
From any `<TrackMenu>`:
1. Operator clicks the kebab on a track row.
2. Hovers/focuses "Add to playlist…" → submenu opens.
3. Submenu lists operator's playlists (`createPlaylistsQuery()` filtered to owned). Each row shows name + small collage.
4. Clicking a playlist appends the track via `appendTracks(playlistID, [trackID])`. Toast: "Added to {playlist name}".
5. Clicking "+ New playlist…" opens an inline dialog (name field, `Create` / `Cancel`). Submitting creates the playlist + appends the track in two API calls (or one if we add a `?with_track=...` query param — implementer choice; default is two calls for simplicity).
### 3.7 Permissions matrix
| Action | Owner | Other authenticated user |
|---------------------------------|:-----:|:------------------------:|
| `GET /api/playlists` (list) | ✓ | ✓ (sees own + others' public) |
| `GET /api/playlists/{id}` | ✓ | ✓ if `is_public`, else 403 |
| `POST /api/playlists` | ✓ (creates own) | ✓ (creates own) |
| `PATCH /api/playlists/{id}` | ✓ | 403 |
| `DELETE /api/playlists/{id}` | ✓ | 403 |
| `POST .../tracks` (append) | ✓ | 403 |
| `DELETE .../tracks/{position}` | ✓ | 403 |
| `PUT .../tracks` (reorder) | ✓ | 403 |
| `GET .../cover` | ✓ | ✓ if playlist visible to caller |
Wire codes: `not_found` (404), `not_authorized` (403, matches existing project convention from #372), `unauthenticated` (401), `bad_request` (400), `server_error` (500). Lidarr is not involved in any of these endpoints — no `lidarr_*` codes.
## 4. File map
### Backend — create
- `internal/db/migrations/0014_playlists.up.sql` + `0014_playlists.down.sql`
- `internal/db/queries/playlists.sql` — Create / Get / Update / Delete / List + tracks insert / delete / reorder
- `internal/playlists/service.go`
- `internal/playlists/service_test.go`
- `internal/playlists/collage.go`
- `internal/playlists/collage_test.go`
- `internal/api/playlists.go`
- `internal/api/playlists_test.go`
### Backend — modify
- `internal/db/dbq/*` — regenerated by `sqlc generate`
- `internal/api/api.go` — register the 9 new routes; add `*playlists.Service` to the `handlers` struct
- `internal/server/server.go` — construct the playlists service; pass to `api.Mount`
### Frontend — create
- `web/src/lib/api/playlists.ts`
- `web/src/lib/api/playlists.test.ts`
- `web/src/lib/components/PlaylistCard.svelte`
- `web/src/lib/components/PlaylistCard.test.ts`
- `web/src/lib/components/AddToPlaylistMenu.svelte`
- `web/src/lib/components/AddToPlaylistMenu.test.ts`
- `web/src/lib/components/PlaylistTrackRow.svelte`
- `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` — wire `<AddToPlaylistMenu>` into the reserved slot; drop the disabled placeholder.
- `web/src/lib/components/TrackMenu.test.ts` — update the "Add to playlist… is disabled" assertion to "is enabled and opens submenu."
- `web/src/lib/api/queries.ts` — add `qk.playlists()`, `qk.playlist(id)`.
- `web/src/lib/api/types.ts` — add `Playlist`, `PlaylistDetail`, `PlaylistTrack` types.
## 5. API contract
### `GET /api/playlists`
Response (200):
```json
{
"owned": [PlaylistRow, ...],
"public": [PlaylistRow, ...]
}
```
Where `PlaylistRow`:
```json
{
"id": "uuid",
"user_id": "uuid",
"owner_username": "alice",
"name": "Saturday morning",
"description": "Mellow start to the weekend",
"is_public": true,
"cover_url": "/api/playlists/{id}/cover",
"track_count": 42,
"duration_sec": 9180,
"created_at": "ts",
"updated_at": "ts"
}
```
`cover_url` is the canonical URL the client renders. Empty playlists → `cover_url = ""`; UI shows the FabledSword glyph fallback.
### `POST /api/playlists`
Body: `{name: string, description?: string, is_public?: boolean}`. Returns the new `PlaylistRow`.
### `GET /api/playlists/{id}`
Returns `PlaylistDetail`:
```json
{
...PlaylistRow,
"tracks": [PlaylistTrack, ...]
}
```
Where `PlaylistTrack`:
```json
{
"position": 0,
"track_id": "uuid", // NULL if track was removed from library
"title": "Roygbiv",
"artist_name": "Boards of Canada",
"album_title": "Music Has The Right To Children",
"album_id": "uuid", // NULL when track_id is NULL (no upstream album to link to)
"artist_id": "uuid", // NULL when track_id is NULL
"duration_sec": 137,
"stream_url": "/api/tracks/uuid/stream", // NULL when track_id is NULL
"added_at": "ts"
}
```
### `PATCH /api/playlists/{id}`
Owner only. Body: `{name?: string, description?: string, is_public?: boolean}`. Returns the updated `PlaylistRow`.
### `DELETE /api/playlists/{id}`
Owner only. 204 on success.
### `POST /api/playlists/{id}/tracks`
Owner only. Body: `{track_ids: [uuid, ...]}`. Appends to the end. Returns the updated `PlaylistDetail`.
### `DELETE /api/playlists/{id}/tracks/{position}`
Owner only. Removes the row at `position`; subsequent rows shift up. Returns the updated `PlaylistDetail`.
### `PUT /api/playlists/{id}/tracks`
Owner only. Body: `{ordered_positions: [int, ...]}` — must be a permutation of the existing positions (e.g., `[3, 0, 1, 2]` if the playlist has 4 entries). Server rewrites all positions. Returns the updated `PlaylistDetail`.
### `GET /api/playlists/{id}/cover`
Returns the cached collage JPEG. 404 when `cover_path` is NULL (UI renders glyph). 403 when caller can't see the playlist.
### Error envelope
All errors use the project's standard `{"error": "code"}` flat shape (the admin-endpoint convention). Codes: `not_found`, `not_authorized`, `unauthenticated`, `bad_request`, `server_error`.
## 6. Error handling (frontend)
- `not_found` (delete/get on missing) → toast "Playlist no longer exists" + invalidate `qk.playlists()`.
- `not_authorized` → toast "You can't edit this playlist." (Defensive — the UI hides edit affordances for non-owners.)
- `bad_request` (e.g., reorder with a non-permutation) → toast the error code's `copyForCode()` message. Reorder rolls back optimistically.
- Network / `connection_refused` → standard `<ConnectionErrorBanner>` from the existing pattern.
- Drag-reorder failures roll back the optimistic local update so the visible order matches the server.
## 7. Testing
### Backend
- `internal/playlists/service_test.go` — happy paths for each method, plus:
- Visibility: caller-not-owner + `is_public=false``ErrForbidden`.
- Visibility: caller-not-owner + `is_public=true` → reads succeed, mutations fail.
- Append populates denormalized snapshot fields correctly.
- Reorder validates permutation (rejects extra/missing/duplicate positions).
- Track delete (`ON DELETE SET NULL`) leaves playlist_tracks row intact with `track_id=NULL` and snapshot still present.
- Cascade: deleting a playlist removes `playlist_tracks` and the cached cover file.
- Rollups (`track_count`, `duration_sec`) update on append / remove / reorder.
- `internal/playlists/collage_test.go` — generates collages for 0 / 1 / 2 / 3 / 4-track playlists, asserts file existence and size; uses image/jpeg fixture decoder to verify dimensions.
- `internal/api/playlists_test.go` — HTTP-level tests for each route, all permissions matrix cases, response envelope shapes.
### Frontend
- `playlists.test.ts` (api helper) — verifies URL shapes, query param handling, error envelope handling.
- `PlaylistCard.test.ts` — renders collage / glyph fallback, name, track_count, owner-vs-other formatting.
- `AddToPlaylistMenu.test.ts` — renders user's playlists alphabetically, "+ New playlist…" creates + adds.
- `PlaylistTrackRow.test.ts` — renders track row, drag handle visible only for owner, greyed-out + strikethrough for `track_id=NULL`.
- `playlists.test.ts` (route) — list page renders both sections, create dialog flow.
- `playlist.test.ts` (route) — detail page renders track list, drag-reorder fires `PUT`, edit/delete buttons hidden for non-owner, public/private chip wired.
## 8. Distribution / migration notes
- **Migration `0014`** introduces two tables. Online migration is safe — no existing rows to backfill. The migration order is: `playlists` first (referenced by `playlist_tracks`'s FK), then `playlist_tracks`. The `playlists.cover_path` column starts NULL for any pre-existing rows (none exist yet), so no special handling.
- `data/playlist_covers/` directory is created by the service on first cover generation; no migration script required.
- `min_client_version` bump: not required. The endpoints are net-new; old web clients (pre-deploy) hide the playlists nav anyway since the placeholder route renders empty.
- No breaking changes elsewhere.
## 9. Origin
M7 #352. Brainstorm 2026-05-02/03 (parked across two sessions; see task-log id 44 on #352 for the mid-stream park-point).
Decisions locked, in order:
1. Slice 1 = CRUD foundation. Slices 2 (system-generated) and 3 (home-page row) are siblings, both in v1, both their own brainstorm/spec/plan cycles.
2. Multi-user model: private by default, owner publishes via `is_public`. Edit/delete/mutate owner-only. Read for owner OR public.
3. Manual playlists only in v1. User-defined smart-rule queries are post-v1. System-generated playlists from #352 slice 2 are still in scope but produce manual-shape `playlist_tracks` rows.
4. `/api/*` only. No Subsonic compat; aligns with `project_subsonic_legacy.md`.
5. Cover art: server-generated 2×2 collage from the first 4 tracks, regenerated synchronously inline on every track-list mutation, cached on disk. Glyph fallback for empty.
6. Track-removal cascade: soft mark with denormalized snapshot. `track_id` becomes NULL on upstream delete; row stays with title/artist/album/duration; UI shows greyed-out strikethrough.
7. Reorder API: full ordered-list rewrite (idempotent, simple).
8. Inline collage generation (synchronous) acceptable for v1; goroutine + disk-cache invalidation can come later if performance becomes an issue.
9. Composite PK on `(playlist_id, position)`; reorder rewrites the PK in-transaction. Acceptable for typical playlist sizes.
10. Denormalized rollups (`track_count`, `duration_sec`) on `playlists`, updated by service-layer on every mutation.
## 10. Related
- M7 #352 — this task (slice 1 of 3). Slices 2 and 3 brainstorm separately.
- M7 #372 (shipped, `1cf58b1`) — track-actions menu reserved the "Add to playlist…" slot for this slice.
- M7 #356 — Flutter mobile client. The playlists schema feeds Flutter slice 2+ when web parity is reached.
- `project_subsonic_legacy.md``/api/*`-only policy that scopes Subsonic out of this slice.
- `feedback_v1_is_full_product.md` — v1 = full product; the three #352 slices all land before tag.