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

23 KiB
Raw Blame History

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)

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.tslistPlaylists, 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):

{
  "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" + 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=falseErrForbidden.
    • 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.
  • 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.