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.
This commit is contained in:
2026-05-03 00:28:34 -04:00
parent 1cf58b18e8
commit 65bb4e6dfd
@@ -0,0 +1,371 @@
# 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.