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.
23 KiB
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
/playlistsUI 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_tracksrows). - Sharing model: private by default, owner publishes via
is_public. Edit / delete / reorder / add / remove tracks are owner-only. Read is owner ORis_public=true. Other users see public playlists in browse but cannot mutate them. - Track-removal cascade: soft mark with denormalized snapshot.
playlist_trackscarriestitle,artist_name,album_title,duration_secdenormalized at time-of-add. When the upstreamtracksrow is deleted,track_idbecomesNULL(FKON 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}/trackswith{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/jpegstdlib. Cached on disk atdata/playlist_covers/{playlist_id}.jpg; path stored inplaylists.cover_path. Empty playlists →cover_path=NULL, UI renders a FabledSword glyph. - API surface lives at
/api/playlists*. No/rest/*Subsonic parity — aligns withproject_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. /playlistsindex 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) onplaylistsso list pages don't aggregate at read time — updated by the service layer on every track-list mutation. - Empty-state copy on the
/playlistsindex 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)
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_idis 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 ofplaylist_tracks. playlists.user_idcascades on user deletion. Acceptable: deleting a user should remove their data.playlists.cover_pathis a relative path (e.g.,playlist_covers/abc.jpg); resolved against the configureddata_dirfromconfig.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 ORis_public).List(ctx, callerUserID) ([]PlaylistRow, error)— owner's all + others' public, ordered byupdated_at DESC.Update(ctx, userID, playlistID, name?, description?, isPublic?) error— owner-only.Delete(ctx, userID, playlistID) error— owner-only. Cascadesplaylist_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. Bumpstrack_count,duration_sec,updated_at. Triggers cover regeneration.RemoveTrack(ctx, userID, playlistID, position) error— owner-only. Deletes the row atposition; renumbers all rows withposition > pto 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/playlistsPOST /api/playlistsGET /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.tsweb/src/lib/components/PlaylistCard.svelte— collage + name + track_count + (creator name when not owned). Used in/playlistslists.web/src/lib/components/PlaylistCard.test.tsweb/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.tsweb/src/lib/components/PlaylistTrackRow.svelte— variant of<TrackRow>for playlist detail. Adds drag handle (owner only), strike-through styling fortrack_id=NULLrows, per-row "Remove" via<TrackMenu>extension.web/src/lib/components/PlaylistTrackRow.test.tsweb/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.tsweb/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 thedisabledflag 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— addqk.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
GripVerticalicon, 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 viacopyForCode).
3.6 "Add to playlist" flow
From any <TrackMenu>:
- Operator clicks the kebab on a track row.
- Hovers/focuses "Add to playlist…" → submenu opens.
- Submenu lists operator's playlists (
createPlaylistsQuery()filtered to owned). Each row shows name + small collage. - Clicking a playlist appends the track via
appendTracks(playlistID, [trackID]). Toast: "Added to {playlist name}". - 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.sqlinternal/db/queries/playlists.sql— Create / Get / Update / Delete / List + tracks insert / delete / reorderinternal/playlists/service.gointernal/playlists/service_test.gointernal/playlists/collage.gointernal/playlists/collage_test.gointernal/api/playlists.gointernal/api/playlists_test.go
Backend — modify
internal/db/dbq/*— regenerated bysqlc generateinternal/api/api.go— register the 9 new routes; add*playlists.Serviceto thehandlersstructinternal/server/server.go— construct the playlists service; pass toapi.Mount
Frontend — create
web/src/lib/api/playlists.tsweb/src/lib/api/playlists.test.tsweb/src/lib/components/PlaylistCard.svelteweb/src/lib/components/PlaylistCard.test.tsweb/src/lib/components/AddToPlaylistMenu.svelteweb/src/lib/components/AddToPlaylistMenu.test.tsweb/src/lib/components/PlaylistTrackRow.svelteweb/src/lib/components/PlaylistTrackRow.test.tsweb/src/routes/playlists/+page.svelte(rewrite of placeholder)web/src/routes/playlists/[id]/+page.svelteweb/src/routes/playlists/playlists.test.tsweb/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— addqk.playlists(),qk.playlist(id).web/src/lib/api/types.ts— addPlaylist,PlaylistDetail,PlaylistTracktypes.
5. API contract
GET /api/playlists
Response (200):
{
"owned": [PlaylistRow, ...],
"public": [PlaylistRow, ...]
}
Where PlaylistRow:
{
"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:
{
...PlaylistRow,
"tracks": [PlaylistTrack, ...]
}
Where PlaylistTrack:
{
"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" + invalidateqk.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'scopyForCode()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 withtrack_id=NULLand snapshot still present. - Cascade: deleting a playlist removes
playlist_tracksand the cached cover file. - Rollups (
track_count,duration_sec) update on append / remove / reorder.
- Visibility: caller-not-owner +
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 fortrack_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 firesPUT, edit/delete buttons hidden for non-owner, public/private chip wired.
8. Distribution / migration notes
- Migration
0014introduces two tables. Online migration is safe — no existing rows to backfill. The migration order is:playlistsfirst (referenced byplaylist_tracks's FK), thenplaylist_tracks. Theplaylists.cover_pathcolumn 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_versionbump: 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:
- 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.
- Multi-user model: private by default, owner publishes via
is_public. Edit/delete/mutate owner-only. Read for owner OR public. - 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_tracksrows. /api/*only. No Subsonic compat; aligns withproject_subsonic_legacy.md.- 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.
- Track-removal cascade: soft mark with denormalized snapshot.
track_idbecomes NULL on upstream delete; row stays with title/artist/album/duration; UI shows greyed-out strikethrough. - Reorder API: full ordered-list rewrite (idempotent, simple).
- Inline collage generation (synchronous) acceptable for v1; goroutine + disk-cache invalidation can come later if performance becomes an issue.
- Composite PK on
(playlist_id, position); reorder rewrites the PK in-transaction. Acceptable for typical playlist sizes. - Denormalized rollups (
track_count,duration_sec) onplaylists, 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.