From 65bb4e6dfd195418521a75314c8ffd8ec6de02cf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 00:28:34 -0400 Subject: [PATCH] docs(m7): spec for #352 playlists CRUD (slice 1 of 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-m7-playlists-crud-design.md | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md diff --git a/docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md b/docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md new file mode 100644 index 00000000..5a592e30 --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md @@ -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 `` 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 `` (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 `` 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 ``'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 `` for playlist detail. Adds drag handle (owner only), strike-through styling for `track_id=NULL` rows, per-row "Remove" via `` 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 `` 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 ``: + +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 `` 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 `` 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.