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