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:
@@ -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.
|
||||
Reference in New Issue
Block a user