e056c8b8be
11 tasks: migration 0014 + sqlc queries (T1), Service CRUD methods (T2), track operations (T3), cover-collage generator (T4), 9 HTTP handlers + service wiring (T5), frontend api helper + types + qk (T6), PlaylistCard (T7), PlaylistTrackRow with drag handle + soft-mark strikethrough (T8), AddToPlaylistMenu wired into TrackMenu (T9), /playlists index page (T10), /playlists/[id] detail with drag-reorder (T11). Soft-mark cascade preserved end-to-end: schema (track_id nullable + ON DELETE SET NULL with denormalized snapshot columns), service (no cascade on track delete; rows persist), UI (greyed-out + strikethrough for null track_id rows). Cover collage is synchronous inline 2x2 JPEG via image/jpeg stdlib; SVG fallback rasterization is a follow-up.
3509 lines
120 KiB
Markdown
3509 lines
120 KiB
Markdown
# M7 — Playlists CRUD (slice 1 of #352) — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Land manually-curated playlists end-to-end: schema (migration 0014), Go service + collage generator, `/api/playlists*` routes, `/playlists` list page, `/playlists/{id}` detail page with drag-to-reorder, and the "Add to playlist…" submenu wired into `<TrackMenu>`'s reserved slot.
|
||
|
||
**Architecture:** New `internal/playlists` package owns CRUD + permissions + cover-collage generation. Synchronous inline collage rendering via Go's `image/jpeg` stdlib on every track-list mutation; cached on disk at `data/playlist_covers/{id}.jpg`. Frontend uses TanStack Query for state, native HTML5 drag-and-drop for reorder. Soft-mark cascade (`track_id` nullable + `ON DELETE SET NULL`) preserves a denormalized snapshot when the upstream track row is deleted, so playlists never silently shrink.
|
||
|
||
**Tech Stack:** Go 1.23 · pgx/v5 + sqlc · Postgres (migration 0014) · `image/jpeg` stdlib · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · Lucide icons · FabledSword design tokens.
|
||
|
||
**Spec:** [`docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md`](../specs/2026-05-03-m7-playlists-crud-design.md). Read it first — every decision is explained there.
|
||
|
||
**Memory dependencies:**
|
||
- `feedback_no_in_task_tests.md` — HARD RULE. Do NOT run `npm test`, `npm run check`, `go test`, `flutter test`, etc. inside any task. Write the test file as code, write the production code, commit. CI runs the suite.
|
||
- `feedback_v1_is_full_product.md` — slice 1 ships complete. Slices 2 (system-generated mixes) + 3 (home-page row) are sibling spec/plan cycles, also in v1.
|
||
- `project_design_system.md` — FabledSword tokens; Lucide icons; sentence case; Moss/Bronze/Oxblood for action buttons (NEVER accent).
|
||
- `project_no_github.md` — Forgejo MCP for any PR/issue ops; never `gh` CLI.
|
||
- `project_subsonic_legacy.md` — `/api/*` only; no `/rest/*` parity.
|
||
- `project_git_workflow.md` — commit on `dev`; PR to `main` separately.
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
### Backend — create
|
||
|
||
- `internal/db/migrations/0014_playlists.up.sql`
|
||
- `internal/db/migrations/0014_playlists.down.sql`
|
||
- `internal/db/queries/playlists.sql` — Create / Get / List / Update / Delete + tracks insert / delete / reorder
|
||
- `internal/playlists/service.go` — `Service` with `Create`, `Get`, `List`, `Update`, `Delete`, `AppendTracks`, `RemoveTrack`, `Reorder`. Typed errors `ErrNotFound`, `ErrForbidden`, `ErrInvalidInput`, `ErrTrackNotFound`.
|
||
- `internal/playlists/service_test.go`
|
||
- `internal/playlists/collage.go` — `GenerateCollage(ctx, q, playlistID, dataDir) (relPath string, err)`. 2×2 grid via `image/jpeg`; missing cells filled with `album-fallback.svg` rasterized to 300×300.
|
||
- `internal/playlists/collage_test.go`
|
||
- `internal/api/playlists.go` — 9 HTTP handlers
|
||
- `internal/api/playlists_test.go`
|
||
|
||
### Backend — modify
|
||
|
||
- `internal/db/dbq/*` — regenerated by `sqlc generate`
|
||
- `internal/api/api.go` — `Mount` gains a `playlistsSvc *playlists.Service` arg; add 9 routes inside `authed.Group(...)`
|
||
- `internal/server/server.go` — construct `playlistsSvc := playlists.NewService(...)` and pass to `api.Mount`
|
||
|
||
### Frontend — create
|
||
|
||
- `web/src/lib/api/playlists.ts` — `listPlaylists`, `getPlaylist`, `createPlaylist`, `updatePlaylist`, `deletePlaylist`, `appendTracks`, `reorderPlaylist`, `removeTrack`. Plus `createPlaylistsQuery()` and `createPlaylistQuery(id)` factories.
|
||
- `web/src/lib/api/playlists.test.ts`
|
||
- `web/src/lib/components/PlaylistCard.svelte` — collage + name + track_count + (creator name when not owned).
|
||
- `web/src/lib/components/PlaylistCard.test.ts`
|
||
- `web/src/lib/components/AddToPlaylistMenu.svelte` — submenu mounted from `<TrackMenu>`'s "Add to playlist…" entry.
|
||
- `web/src/lib/components/AddToPlaylistMenu.test.ts`
|
||
- `web/src/lib/components/PlaylistTrackRow.svelte` — variant of `<TrackRow>` for the detail page; drag handle + strikethrough for `track_id=NULL`.
|
||
- `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` — replace the disabled "Add to playlist…" entry with a real opener that mounts `<AddToPlaylistMenu>`.
|
||
- `web/src/lib/components/TrackMenu.test.ts` — drop the "Add to playlist… is disabled" assertion; replace with one verifying the entry is enabled.
|
||
- `web/src/lib/api/queries.ts` — add `qk.playlists()`, `qk.playlist(id)`.
|
||
- `web/src/lib/api/types.ts` — add `Playlist`, `PlaylistDetail`, `PlaylistTrack` types.
|
||
|
||
---
|
||
|
||
## Task list
|
||
|
||
### Task 1 — Migration 0014 + sqlc queries
|
||
|
||
**Files:**
|
||
- Create: `internal/db/migrations/0014_playlists.up.sql`
|
||
- Create: `internal/db/migrations/0014_playlists.down.sql`
|
||
- Create: `internal/db/queries/playlists.sql`
|
||
- Regenerate: `internal/db/dbq/*` via `sqlc generate`
|
||
|
||
- [ ] **Step 1.1: Write `internal/db/migrations/0014_playlists.up.sql`**
|
||
|
||
```sql
|
||
-- M7 #352 slice 1: playlists CRUD foundation.
|
||
-- See docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md.
|
||
|
||
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,
|
||
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;
|
||
|
||
-- track_id is nullable + ON DELETE SET NULL so deleting a track from
|
||
-- the library doesn't silently drop entries from operators' playlists.
|
||
-- Denormalized title/artist/album/duration carry a snapshot so the row
|
||
-- is still legible after the upstream track row is gone.
|
||
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,
|
||
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)
|
||
);
|
||
|
||
-- Partial index supports 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.
|
||
CREATE INDEX playlist_tracks_track_idx ON playlist_tracks (track_id) WHERE track_id IS NOT NULL;
|
||
```
|
||
|
||
- [ ] **Step 1.2: Write `internal/db/migrations/0014_playlists.down.sql`**
|
||
|
||
```sql
|
||
DROP TABLE IF EXISTS playlist_tracks;
|
||
DROP TABLE IF EXISTS playlists;
|
||
```
|
||
|
||
- [ ] **Step 1.3: Write `internal/db/queries/playlists.sql`**
|
||
|
||
```sql
|
||
-- name: CreatePlaylist :one
|
||
INSERT INTO playlists (user_id, name, description, is_public)
|
||
VALUES ($1, $2, $3, $4)
|
||
RETURNING *;
|
||
|
||
-- name: GetPlaylist :one
|
||
SELECT p.*, u.username AS owner_username
|
||
FROM playlists p
|
||
JOIN users u ON u.id = p.user_id
|
||
WHERE p.id = $1;
|
||
|
||
-- name: ListPlaylistsForUser :many
|
||
-- Owner's playlists (any visibility) + other users' public playlists.
|
||
-- Ordered by updated_at desc so newly-edited ones float to the top.
|
||
SELECT p.*, u.username AS owner_username
|
||
FROM playlists p
|
||
JOIN users u ON u.id = p.user_id
|
||
WHERE p.user_id = $1 OR p.is_public = true
|
||
ORDER BY p.updated_at DESC;
|
||
|
||
-- name: UpdatePlaylist :one
|
||
-- Updates only the fields whose corresponding `updateX` flag is true.
|
||
-- The flags let the service layer keep PATCH semantics (only-touch-what-the-caller-sent)
|
||
-- without writing N variants.
|
||
UPDATE playlists
|
||
SET
|
||
name = CASE WHEN sqlc.arg(update_name)::boolean THEN sqlc.arg(name)::text ELSE name END,
|
||
description = CASE WHEN sqlc.arg(update_description)::boolean THEN sqlc.arg(description)::text ELSE description END,
|
||
is_public = CASE WHEN sqlc.arg(update_is_public)::boolean THEN sqlc.arg(is_public)::boolean ELSE is_public END,
|
||
updated_at = now()
|
||
WHERE id = sqlc.arg(id)
|
||
RETURNING *;
|
||
|
||
-- name: UpdatePlaylistRollups :exec
|
||
-- Set track_count + duration_sec from a fresh aggregate. Called after
|
||
-- every mutation that touches playlist_tracks. Cheap; the table is small.
|
||
UPDATE playlists
|
||
SET
|
||
track_count = (SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = $1),
|
||
duration_sec = (SELECT COALESCE(SUM(duration_sec), 0) FROM playlist_tracks WHERE playlist_id = $1),
|
||
updated_at = now()
|
||
WHERE id = $1;
|
||
|
||
-- name: SetPlaylistCover :exec
|
||
UPDATE playlists SET cover_path = $2, updated_at = now() WHERE id = $1;
|
||
|
||
-- name: DeletePlaylist :one
|
||
-- Returns cover_path so the caller can clean up the cached collage on disk.
|
||
DELETE FROM playlists WHERE id = $1
|
||
RETURNING id, cover_path;
|
||
|
||
-- name: ListPlaylistTracks :many
|
||
-- Joined to tracks for the live stream_url; LEFT JOIN preserves the row
|
||
-- when track_id is NULL (track was removed from the library). The
|
||
-- denormalized snapshot fields on playlist_tracks remain authoritative
|
||
-- for title/artist/album text.
|
||
SELECT pt.*,
|
||
t.id AS live_track_id,
|
||
albums.id AS album_id,
|
||
artists.id AS artist_id
|
||
FROM playlist_tracks pt
|
||
LEFT JOIN tracks t ON t.id = pt.track_id
|
||
LEFT JOIN albums ON albums.id = t.album_id
|
||
LEFT JOIN artists ON artists.id = t.artist_id
|
||
WHERE pt.playlist_id = $1
|
||
ORDER BY pt.position;
|
||
|
||
-- name: AppendPlaylistTrack :one
|
||
-- Inserts at the next available position. Snapshot fields are copied
|
||
-- from the tracks/albums/artists join at insert time.
|
||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
|
||
SELECT
|
||
sqlc.arg(playlist_id)::uuid,
|
||
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0),
|
||
t.id,
|
||
t.title,
|
||
artists.name,
|
||
albums.title,
|
||
t.duration_sec
|
||
FROM tracks t
|
||
JOIN albums ON albums.id = t.album_id
|
||
JOIN artists ON artists.id = t.artist_id
|
||
WHERE t.id = sqlc.arg(track_id)::uuid
|
||
RETURNING *;
|
||
|
||
-- name: DeletePlaylistTrack :exec
|
||
-- Two-step: delete the row at `position`, then renumber subsequent rows
|
||
-- to close the gap. The renumber is a single UPDATE; the service layer
|
||
-- runs both in one transaction.
|
||
DELETE FROM playlist_tracks
|
||
WHERE playlist_id = $1 AND position = $2;
|
||
|
||
-- name: RenumberPlaylistTracksAfter :exec
|
||
-- Used after DeletePlaylistTrack to close the gap.
|
||
UPDATE playlist_tracks
|
||
SET position = position - 1
|
||
WHERE playlist_id = $1 AND position > $2;
|
||
|
||
-- name: ReplacePlaylistTracksOrder :exec
|
||
-- Reorder strategy: delete + reinsert is awkward because the snapshot
|
||
-- fields need preserving. Instead, use a temporary offset to push every
|
||
-- row out of the position range, then write the new positions.
|
||
-- Implementation note: the service layer drives this with two UPDATE
|
||
-- statements per track in a transaction (offset by +10000, then back to
|
||
-- the new position). See service.go for the orchestration.
|
||
SELECT 1; -- placeholder; service uses ad-hoc UPDATEs
|
||
|
||
-- name: ListAllPlaylistTracksForCollage :many
|
||
-- First N tracks for the collage. Uses LEFT JOIN on albums for the
|
||
-- cover_path; rows with NULL cover_path get the glyph fallback.
|
||
SELECT pt.position,
|
||
albums.cover_art_path AS album_cover_path
|
||
FROM playlist_tracks pt
|
||
LEFT JOIN tracks t ON t.id = pt.track_id
|
||
LEFT JOIN albums ON albums.id = t.album_id
|
||
WHERE pt.playlist_id = $1
|
||
ORDER BY pt.position
|
||
LIMIT $2;
|
||
```
|
||
|
||
Note on the `Update` query: sqlc supports `sqlc.arg(name)::type` syntax to give parameters explicit names. The `CASE WHEN updateX::boolean THEN ...` pattern is the project's idiomatic PATCH.
|
||
|
||
The `ReplacePlaylistTracksOrder` is a placeholder — sqlc generates a no-op function. The actual reorder logic lives in the service layer using ad-hoc UPDATE statements wrapped in a transaction (it's not a single declarative SQL statement). The placeholder exists so the operation has a name in the codebase and reviewers can find the logic.
|
||
|
||
- [ ] **Step 1.4: Run sqlc generate**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel
|
||
sqlc generate
|
||
```
|
||
|
||
Expected: `internal/db/dbq/playlists.sql.go` created with the new query methods.
|
||
|
||
- [ ] **Step 1.5: Commit**
|
||
|
||
```bash
|
||
git add internal/db/migrations/0014_playlists.up.sql internal/db/migrations/0014_playlists.down.sql internal/db/queries/playlists.sql internal/db/dbq/
|
||
git commit -F - <<'EOF'
|
||
feat(db): playlists schema for M7 #352 slice 1
|
||
|
||
Migration 0014 adds playlists + playlist_tracks. track_id is nullable
|
||
with ON DELETE SET NULL — tracks can be removed from the library
|
||
without silently dropping playlist entries; the denormalized snapshot
|
||
(title/artist/album/duration) keeps the row legible afterwards. UI
|
||
renders such rows greyed-out.
|
||
|
||
Indexes: playlists by (user_id, updated_at DESC) and a partial public
|
||
index for cross-user discovery; playlist_tracks partial index on
|
||
track_id to support the FK SET NULL lookup.
|
||
|
||
Queries provide CRUD + rollup recompute (track_count, duration_sec)
|
||
+ append/remove/reorder primitives. Reorder is service-layer
|
||
orchestrated; the SQL placeholder is intentional.
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2 — Playlists service: CRUD methods
|
||
|
||
**Files:**
|
||
- Create: `internal/playlists/service.go`
|
||
- Create: `internal/playlists/service_test.go` (CRUD-only cases for this task; track-ops tests land in Task 3)
|
||
|
||
This task lands `Service` with `Create`, `Get`, `List`, `Update`, `Delete`. Track-list mutation methods come in Task 3 to keep the diff focused.
|
||
|
||
- [ ] **Step 2.1: Write `internal/playlists/service.go`**
|
||
|
||
```go
|
||
package playlists
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
// Typed errors. Mapped to wire codes by the API layer.
|
||
var (
|
||
ErrNotFound = errors.New("playlist not found")
|
||
ErrForbidden = errors.New("forbidden")
|
||
ErrInvalidInput = errors.New("invalid input")
|
||
ErrTrackNotFound = errors.New("track not found")
|
||
)
|
||
|
||
type Service struct {
|
||
pool *pgxpool.Pool
|
||
logger *slog.Logger
|
||
dataDir string // root of `data/`; collages live under `<dataDir>/playlist_covers/`
|
||
}
|
||
|
||
func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Service {
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
return &Service{pool: pool, logger: logger, dataDir: dataDir}
|
||
}
|
||
|
||
// PlaylistRow mirrors the wire shape returned by Get/List/Create/Update.
|
||
// The denormalized owner_username comes from the JOIN in the query.
|
||
type PlaylistRow struct {
|
||
ID pgtype.UUID
|
||
UserID pgtype.UUID
|
||
OwnerUsername string
|
||
Name string
|
||
Description string
|
||
IsPublic bool
|
||
CoverPath *string
|
||
TrackCount int32
|
||
DurationSec int32
|
||
CreatedAt pgtype.Timestamptz
|
||
UpdatedAt pgtype.Timestamptz
|
||
}
|
||
|
||
// PlaylistDetail extends PlaylistRow with the track list. The slice is
|
||
// always fully loaded (no pagination in slice 1 — playlists are small).
|
||
type PlaylistDetail struct {
|
||
PlaylistRow
|
||
Tracks []PlaylistTrack
|
||
}
|
||
|
||
// PlaylistTrack is one row in a playlist's track list. TrackID is nil
|
||
// when the upstream track has been removed from the library.
|
||
type PlaylistTrack struct {
|
||
Position int32
|
||
TrackID *pgtype.UUID
|
||
AlbumID *pgtype.UUID
|
||
ArtistID *pgtype.UUID
|
||
Title string
|
||
ArtistName string
|
||
AlbumTitle string
|
||
DurationSec int32
|
||
AddedAt pgtype.Timestamptz
|
||
}
|
||
|
||
// --- CRUD ---
|
||
|
||
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, name, description string, isPublic bool) (*PlaylistRow, error) {
|
||
if name == "" {
|
||
return nil, fmt.Errorf("%w: name required", ErrInvalidInput)
|
||
}
|
||
q := dbq.New(s.pool)
|
||
row, err := q.CreatePlaylist(ctx, dbq.CreatePlaylistParams{
|
||
UserID: userID,
|
||
Name: name,
|
||
Description: description,
|
||
IsPublic: isPublic,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create playlist: %w", err)
|
||
}
|
||
// Re-fetch to get owner_username via the join.
|
||
full, err := q.GetPlaylist(ctx, row.ID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get-after-create: %w", err)
|
||
}
|
||
return playlistFromGetRow(full), nil
|
||
}
|
||
|
||
func (s *Service) Get(ctx context.Context, callerID, playlistID pgtype.UUID) (*PlaylistDetail, error) {
|
||
q := dbq.New(s.pool)
|
||
row, err := q.GetPlaylist(ctx, playlistID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return nil, fmt.Errorf("get playlist: %w", err)
|
||
}
|
||
if !row.IsPublic && !pgtypeUUIDEqual(row.UserID, callerID) {
|
||
return nil, ErrForbidden
|
||
}
|
||
|
||
tracksRows, err := q.ListPlaylistTracks(ctx, playlistID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list tracks: %w", err)
|
||
}
|
||
tracks := make([]PlaylistTrack, 0, len(tracksRows))
|
||
for _, t := range tracksRows {
|
||
pt := PlaylistTrack{
|
||
Position: t.Position,
|
||
Title: t.Title,
|
||
ArtistName: t.ArtistName,
|
||
AlbumTitle: t.AlbumTitle,
|
||
DurationSec: t.DurationSec,
|
||
AddedAt: t.AddedAt,
|
||
}
|
||
if t.TrackID.Valid {
|
||
id := t.TrackID
|
||
pt.TrackID = &id
|
||
}
|
||
if t.AlbumID.Valid {
|
||
id := t.AlbumID
|
||
pt.AlbumID = &id
|
||
}
|
||
if t.ArtistID.Valid {
|
||
id := t.ArtistID
|
||
pt.ArtistID = &id
|
||
}
|
||
tracks = append(tracks, pt)
|
||
}
|
||
|
||
return &PlaylistDetail{
|
||
PlaylistRow: *playlistFromGetRow(row),
|
||
Tracks: tracks,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) List(ctx context.Context, callerID pgtype.UUID) ([]PlaylistRow, error) {
|
||
q := dbq.New(s.pool)
|
||
rows, err := q.ListPlaylistsForUser(ctx, callerID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list playlists: %w", err)
|
||
}
|
||
out := make([]PlaylistRow, 0, len(rows))
|
||
for _, r := range rows {
|
||
out = append(out, *playlistFromListRow(r))
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// UpdateInput captures the optional fields PATCH can change. nil means
|
||
// "don't touch this field."
|
||
type UpdateInput struct {
|
||
Name *string
|
||
Description *string
|
||
IsPublic *bool
|
||
}
|
||
|
||
func (s *Service) Update(ctx context.Context, callerID, playlistID pgtype.UUID, in UpdateInput) (*PlaylistRow, error) {
|
||
q := dbq.New(s.pool)
|
||
existing, err := q.GetPlaylist(ctx, playlistID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
return nil, fmt.Errorf("get-before-update: %w", err)
|
||
}
|
||
if !pgtypeUUIDEqual(existing.UserID, callerID) {
|
||
return nil, ErrForbidden
|
||
}
|
||
|
||
params := dbq.UpdatePlaylistParams{
|
||
ID: playlistID,
|
||
UpdateName: in.Name != nil,
|
||
UpdateDescription: in.Description != nil,
|
||
UpdateIsPublic: in.IsPublic != nil,
|
||
}
|
||
if in.Name != nil {
|
||
if *in.Name == "" {
|
||
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
|
||
}
|
||
params.Name = *in.Name
|
||
}
|
||
if in.Description != nil {
|
||
params.Description = *in.Description
|
||
}
|
||
if in.IsPublic != nil {
|
||
params.IsPublic = *in.IsPublic
|
||
}
|
||
|
||
updated, err := q.UpdatePlaylist(ctx, params)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("update playlist: %w", err)
|
||
}
|
||
full, err := q.GetPlaylist(ctx, updated.ID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("get-after-update: %w", err)
|
||
}
|
||
return playlistFromGetRow(full), nil
|
||
}
|
||
|
||
func (s *Service) Delete(ctx context.Context, callerID, playlistID pgtype.UUID) error {
|
||
q := dbq.New(s.pool)
|
||
existing, err := q.GetPlaylist(ctx, playlistID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
return fmt.Errorf("get-before-delete: %w", err)
|
||
}
|
||
if !pgtypeUUIDEqual(existing.UserID, callerID) {
|
||
return ErrForbidden
|
||
}
|
||
|
||
deleted, err := q.DeletePlaylist(ctx, playlistID)
|
||
if err != nil {
|
||
return fmt.Errorf("delete playlist: %w", err)
|
||
}
|
||
// Best-effort cover-file cleanup. ENOENT is fine.
|
||
if deleted.CoverPath != nil && *deleted.CoverPath != "" {
|
||
full := filepath.Join(s.dataDir, *deleted.CoverPath)
|
||
if rerr := os.Remove(full); rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
|
||
s.logger.Warn("playlist delete: cover file remove failed",
|
||
"path", full, "playlist_id", playlistID, "err", rerr)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// --- helpers ---
|
||
|
||
func playlistFromGetRow(r dbq.GetPlaylistRow) *PlaylistRow {
|
||
return &PlaylistRow{
|
||
ID: r.ID,
|
||
UserID: r.UserID,
|
||
OwnerUsername: r.OwnerUsername,
|
||
Name: r.Name,
|
||
Description: r.Description,
|
||
IsPublic: r.IsPublic,
|
||
CoverPath: r.CoverPath,
|
||
TrackCount: r.TrackCount,
|
||
DurationSec: r.DurationSec,
|
||
CreatedAt: r.CreatedAt,
|
||
UpdatedAt: r.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
func playlistFromListRow(r dbq.ListPlaylistsForUserRow) *PlaylistRow {
|
||
return &PlaylistRow{
|
||
ID: r.ID,
|
||
UserID: r.UserID,
|
||
OwnerUsername: r.OwnerUsername,
|
||
Name: r.Name,
|
||
Description: r.Description,
|
||
IsPublic: r.IsPublic,
|
||
CoverPath: r.CoverPath,
|
||
TrackCount: r.TrackCount,
|
||
DurationSec: r.DurationSec,
|
||
CreatedAt: r.CreatedAt,
|
||
UpdatedAt: r.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
func pgtypeUUIDEqual(a, b pgtype.UUID) bool {
|
||
if !a.Valid || !b.Valid {
|
||
return false
|
||
}
|
||
return a.Bytes == b.Bytes
|
||
}
|
||
```
|
||
|
||
If sqlc generates field names in a different shape (e.g., `Mbid` vs `Mbid`), adjust the row-mapping helpers. Read `dbq/playlists.sql.go` after Step 1.4 to see what was actually generated.
|
||
|
||
If `dbq.GetPlaylistRow` has `CoverPath` as `pgtype.Text` instead of `*string`, adapt by checking `.Valid` and dereferencing `.String`. Same applies to all the other fields the queries return.
|
||
|
||
- [ ] **Step 2.2: Write `internal/playlists/service_test.go` (CRUD cases)**
|
||
|
||
```go
|
||
package playlists_test
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||
)
|
||
|
||
// Helpers seedUser, newPool follow the patterns in
|
||
// internal/lidarrquarantine/service_test.go and internal/tracks/service_test.go.
|
||
// Copy them into a service_test_helpers_test.go in this package, or inline.
|
||
|
||
func TestCreate_HappyPath(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
dir := t.TempDir()
|
||
|
||
svc := playlists.NewService(pool, nil, dir)
|
||
row, err := svc.Create(context.Background(), user.ID, "Saturday morning", "Mellow", false)
|
||
if err != nil {
|
||
t.Fatalf("Create: %v", err)
|
||
}
|
||
if row.Name != "Saturday morning" {
|
||
t.Errorf("name = %q, want Saturday morning", row.Name)
|
||
}
|
||
if row.IsPublic {
|
||
t.Error("IsPublic = true, want false (default)")
|
||
}
|
||
if row.OwnerUsername != "alice" {
|
||
t.Errorf("OwnerUsername = %q, want alice", row.OwnerUsername)
|
||
}
|
||
if row.TrackCount != 0 || row.DurationSec != 0 {
|
||
t.Errorf("rollups should start at 0; got count=%d duration=%d", row.TrackCount, row.DurationSec)
|
||
}
|
||
}
|
||
|
||
func TestCreate_EmptyNameRejected(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
_, err := svc.Create(context.Background(), user.ID, "", "", false)
|
||
if !errors.Is(err, playlists.ErrInvalidInput) {
|
||
t.Errorf("err = %v, want ErrInvalidInput", err)
|
||
}
|
||
}
|
||
|
||
func TestGet_NotFound(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
_, err := svc.Get(context.Background(), user.ID, randomUUID())
|
||
if !errors.Is(err, playlists.ErrNotFound) {
|
||
t.Errorf("err = %v, want ErrNotFound", err)
|
||
}
|
||
}
|
||
|
||
func TestGet_Forbidden_PrivatePlaylistFromOtherUser(t *testing.T) {
|
||
pool := newPool(t)
|
||
alice := seedUser(t, pool, "alice")
|
||
bob := seedUser(t, pool, "bob")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
pl, _ := svc.Create(context.Background(), alice.ID, "Private", "", false)
|
||
|
||
_, err := svc.Get(context.Background(), bob.ID, pl.ID)
|
||
if !errors.Is(err, playlists.ErrForbidden) {
|
||
t.Errorf("err = %v, want ErrForbidden", err)
|
||
}
|
||
}
|
||
|
||
func TestGet_Public_VisibleToOtherUser(t *testing.T) {
|
||
pool := newPool(t)
|
||
alice := seedUser(t, pool, "alice")
|
||
bob := seedUser(t, pool, "bob")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
pl, _ := svc.Create(context.Background(), alice.ID, "Public mix", "", true)
|
||
|
||
got, err := svc.Get(context.Background(), bob.ID, pl.ID)
|
||
if err != nil {
|
||
t.Fatalf("Get: %v", err)
|
||
}
|
||
if got.Name != "Public mix" {
|
||
t.Errorf("name = %q", got.Name)
|
||
}
|
||
}
|
||
|
||
func TestList_OwnAndPublic(t *testing.T) {
|
||
pool := newPool(t)
|
||
alice := seedUser(t, pool, "alice")
|
||
bob := seedUser(t, pool, "bob")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
_, _ = svc.Create(context.Background(), alice.ID, "Alice private", "", false)
|
||
_, _ = svc.Create(context.Background(), alice.ID, "Alice public", "", true)
|
||
_, _ = svc.Create(context.Background(), bob.ID, "Bob private", "", false)
|
||
_, _ = svc.Create(context.Background(), bob.ID, "Bob public", "", true)
|
||
|
||
// Alice sees: her own (both) + Bob's public = 3.
|
||
rows, err := svc.List(context.Background(), alice.ID)
|
||
if err != nil {
|
||
t.Fatalf("List: %v", err)
|
||
}
|
||
if len(rows) != 3 {
|
||
t.Errorf("alice's list = %d rows, want 3", len(rows))
|
||
}
|
||
}
|
||
|
||
func TestUpdate_OwnerOnly(t *testing.T) {
|
||
pool := newPool(t)
|
||
alice := seedUser(t, pool, "alice")
|
||
bob := seedUser(t, pool, "bob")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
pl, _ := svc.Create(context.Background(), alice.ID, "Mine", "", false)
|
||
|
||
rename := "Renamed"
|
||
_, err := svc.Update(context.Background(), bob.ID, pl.ID, playlists.UpdateInput{Name: &rename})
|
||
if !errors.Is(err, playlists.ErrForbidden) {
|
||
t.Errorf("err = %v, want ErrForbidden", err)
|
||
}
|
||
|
||
updated, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{Name: &rename})
|
||
if err != nil {
|
||
t.Fatalf("owner Update: %v", err)
|
||
}
|
||
if updated.Name != "Renamed" {
|
||
t.Errorf("name = %q, want Renamed", updated.Name)
|
||
}
|
||
}
|
||
|
||
func TestDelete_RemovesCoverFile(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
dir := t.TempDir()
|
||
svc := playlists.NewService(pool, nil, dir)
|
||
|
||
pl, _ := svc.Create(context.Background(), user.ID, "Will be deleted", "", false)
|
||
// Simulate a previously-generated cover by creating the file.
|
||
coverDir := filepath.Join(dir, "playlist_covers")
|
||
_ = os.MkdirAll(coverDir, 0o755)
|
||
relPath := filepath.Join("playlist_covers", uuidString(pl.ID)+".jpg")
|
||
full := filepath.Join(dir, relPath)
|
||
if err := os.WriteFile(full, []byte("jpeg-bytes"), 0o644); err != nil {
|
||
t.Fatalf("seed cover file: %v", err)
|
||
}
|
||
// Set the cover_path on the playlist row.
|
||
if _, err := pool.Exec(context.Background(),
|
||
`UPDATE playlists SET cover_path = $1 WHERE id = $2`, relPath, pl.ID); err != nil {
|
||
t.Fatalf("set cover_path: %v", err)
|
||
}
|
||
|
||
if err := svc.Delete(context.Background(), user.ID, pl.ID); err != nil {
|
||
t.Fatalf("Delete: %v", err)
|
||
}
|
||
if _, statErr := os.Stat(full); !errors.Is(statErr, os.ErrNotExist) {
|
||
t.Errorf("cover file still on disk after delete")
|
||
}
|
||
}
|
||
```
|
||
|
||
The seed helpers (`newPool`, `seedUser`, `randomUUID`, `uuidString`) — copy from `internal/tracks/service_test.go` (which copied from `internal/lidarrquarantine/service_test.go`). Tests are integration-style and require `MINSTREL_TEST_DATABASE_URL`.
|
||
|
||
- [ ] **Step 2.3: Commit**
|
||
|
||
```bash
|
||
git add internal/playlists/service.go internal/playlists/service_test.go
|
||
git commit -F - <<'EOF'
|
||
feat(playlists): Service with CRUD methods (M7 #352 slice 1)
|
||
|
||
Create / Get / List / Update / Delete with the visibility model from
|
||
the spec: private by default, owner-only mutations, public read for
|
||
non-owners. Update uses sqlc's CASE-WHEN-flag pattern for PATCH-style
|
||
partial updates without writing N variants.
|
||
|
||
Delete cleans up the cached cover file from disk best-effort. Track
|
||
operations (Append/Remove/Reorder) and the collage generator land
|
||
in subsequent tasks.
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3 — Playlists service: track operations
|
||
|
||
**Files:**
|
||
- Modify: `internal/playlists/service.go` — add `AppendTracks`, `RemoveTrack`, `Reorder`
|
||
- Modify: `internal/playlists/service_test.go` — track-ops cases
|
||
|
||
- [ ] **Step 3.1: Append `AppendTracks`, `RemoveTrack`, `Reorder` to `internal/playlists/service.go`**
|
||
|
||
Add after the `Delete` method:
|
||
|
||
```go
|
||
// AppendTracks adds the given track ids at the end of the playlist,
|
||
// preserving order. Snapshot fields (title, artist, album, duration_sec)
|
||
// are populated from the tracks/albums/artists join at insert time.
|
||
// Triggers cover regeneration after.
|
||
func (s *Service) AppendTracks(ctx context.Context, callerID, playlistID pgtype.UUID, trackIDs []pgtype.UUID) error {
|
||
if len(trackIDs) == 0 {
|
||
return fmt.Errorf("%w: at least one track id required", ErrInvalidInput)
|
||
}
|
||
q := dbq.New(s.pool)
|
||
pl, err := q.GetPlaylist(ctx, playlistID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
return fmt.Errorf("get playlist: %w", err)
|
||
}
|
||
if !pgtypeUUIDEqual(pl.UserID, callerID) {
|
||
return ErrForbidden
|
||
}
|
||
|
||
tx, err := s.pool.Begin(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
tq := dbq.New(tx)
|
||
|
||
for _, tid := range trackIDs {
|
||
_, ierr := tq.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
|
||
PlaylistID: playlistID,
|
||
TrackID: tid,
|
||
})
|
||
if ierr != nil {
|
||
// pgx wraps a "no rows returned" into something we have to detect;
|
||
// the AppendPlaylistTrack query uses INSERT ... SELECT FROM tracks WHERE
|
||
// id = X — if X doesn't exist, no row inserts and the :one query errors.
|
||
if errors.Is(ierr, pgx.ErrNoRows) {
|
||
return ErrTrackNotFound
|
||
}
|
||
return fmt.Errorf("append track: %w", ierr)
|
||
}
|
||
}
|
||
if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil {
|
||
return fmt.Errorf("update rollups: %w", err)
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return fmt.Errorf("commit: %w", err)
|
||
}
|
||
|
||
// Regenerate cover (synchronous). Failure is logged but not returned —
|
||
// the playlist mutation already committed.
|
||
if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
|
||
s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RemoveTrack deletes the row at `position` and renumbers subsequent
|
||
// rows to close the gap. Triggers cover regeneration after.
|
||
func (s *Service) RemoveTrack(ctx context.Context, callerID, playlistID pgtype.UUID, position int32) error {
|
||
q := dbq.New(s.pool)
|
||
pl, err := q.GetPlaylist(ctx, playlistID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
return fmt.Errorf("get playlist: %w", err)
|
||
}
|
||
if !pgtypeUUIDEqual(pl.UserID, callerID) {
|
||
return ErrForbidden
|
||
}
|
||
|
||
tx, err := s.pool.Begin(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
tq := dbq.New(tx)
|
||
|
||
if err := tq.DeletePlaylistTrack(ctx, dbq.DeletePlaylistTrackParams{
|
||
PlaylistID: playlistID,
|
||
Position: position,
|
||
}); err != nil {
|
||
return fmt.Errorf("delete track row: %w", err)
|
||
}
|
||
if err := tq.RenumberPlaylistTracksAfter(ctx, dbq.RenumberPlaylistTracksAfterParams{
|
||
PlaylistID: playlistID,
|
||
Position: position,
|
||
}); err != nil {
|
||
return fmt.Errorf("renumber: %w", err)
|
||
}
|
||
if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil {
|
||
return fmt.Errorf("update rollups: %w", err)
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return fmt.Errorf("commit: %w", err)
|
||
}
|
||
|
||
if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
|
||
s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Reorder atomically rewrites all positions to the supplied permutation.
|
||
// orderedPositions is the new positional order: if the playlist has 4
|
||
// tracks at positions [0, 1, 2, 3] and the caller wants the order
|
||
// (old[3], old[0], old[1], old[2]), they send [3, 0, 1, 2].
|
||
func (s *Service) Reorder(ctx context.Context, callerID, playlistID pgtype.UUID, orderedPositions []int32) error {
|
||
q := dbq.New(s.pool)
|
||
pl, err := q.GetPlaylist(ctx, playlistID)
|
||
if err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrNotFound
|
||
}
|
||
return fmt.Errorf("get playlist: %w", err)
|
||
}
|
||
if !pgtypeUUIDEqual(pl.UserID, callerID) {
|
||
return ErrForbidden
|
||
}
|
||
|
||
// Validate: orderedPositions must be a permutation of 0..N-1 where
|
||
// N = len(orderedPositions) AND match the number of rows in the playlist.
|
||
if int32(len(orderedPositions)) != pl.TrackCount {
|
||
return fmt.Errorf("%w: expected %d positions, got %d", ErrInvalidInput, pl.TrackCount, len(orderedPositions))
|
||
}
|
||
seen := make(map[int32]struct{}, len(orderedPositions))
|
||
for _, p := range orderedPositions {
|
||
if p < 0 || p >= pl.TrackCount {
|
||
return fmt.Errorf("%w: position %d out of range", ErrInvalidInput, p)
|
||
}
|
||
if _, dup := seen[p]; dup {
|
||
return fmt.Errorf("%w: position %d duplicated", ErrInvalidInput, p)
|
||
}
|
||
seen[p] = struct{}{}
|
||
}
|
||
|
||
// Rewrite strategy: bump every row's position by +10000 first (out of
|
||
// range of valid positions and the unique PK) then write the new
|
||
// positions one by one. Single transaction.
|
||
tx, err := s.pool.Begin(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
||
if _, err := tx.Exec(ctx,
|
||
`UPDATE playlist_tracks SET position = position + 10000 WHERE playlist_id = $1`,
|
||
playlistID); err != nil {
|
||
return fmt.Errorf("offset positions: %w", err)
|
||
}
|
||
for newPos, oldPos := range orderedPositions {
|
||
if _, err := tx.Exec(ctx,
|
||
`UPDATE playlist_tracks SET position = $1 WHERE playlist_id = $2 AND position = $3`,
|
||
int32(newPos), playlistID, oldPos+10000); err != nil {
|
||
return fmt.Errorf("write new position: %w", err)
|
||
}
|
||
}
|
||
if err := dbq.New(tx).UpdatePlaylistRollups(ctx, playlistID); err != nil {
|
||
return fmt.Errorf("update rollups: %w", err)
|
||
}
|
||
if err := tx.Commit(ctx); err != nil {
|
||
return fmt.Errorf("commit: %w", err)
|
||
}
|
||
|
||
// First-4 changed? Reorder always potentially affects the first 4
|
||
// (the collage uses positions 0..3). Regenerate unconditionally —
|
||
// cheap, and reasoning about which moves matter is more error-prone
|
||
// than just doing the work.
|
||
if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
|
||
s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
The `tx.Exec` for the +10000 offset uses raw SQL because there isn't a sqlc query for it (it's a service-layer-only mechanism). That's fine; it's a one-line statement that doesn't need codegen overhead.
|
||
|
||
Note: `GenerateCollage` doesn't exist yet — it lands in Task 4. Compilation will fail at this point, but the next task fixes that. The implementer should complete Task 3 + Task 4 before pushing.
|
||
|
||
- [ ] **Step 3.2: Append track-ops tests to `internal/playlists/service_test.go`**
|
||
|
||
```go
|
||
func TestAppendTracks_Snapshot(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
track := seedTrack(t, pool, "Roygbiv", "Boards of Canada")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||
if err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{track.ID}); err != nil {
|
||
t.Fatalf("AppendTracks: %v", err)
|
||
}
|
||
|
||
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
|
||
if len(got.Tracks) != 1 {
|
||
t.Fatalf("Tracks length = %d, want 1", len(got.Tracks))
|
||
}
|
||
if got.Tracks[0].Title != "Roygbiv" {
|
||
t.Errorf("Title snapshot = %q, want Roygbiv", got.Tracks[0].Title)
|
||
}
|
||
if got.Tracks[0].ArtistName != "Boards of Canada" {
|
||
t.Errorf("ArtistName snapshot = %q, want Boards of Canada", got.Tracks[0].ArtistName)
|
||
}
|
||
if got.TrackCount != 1 {
|
||
t.Errorf("rollup TrackCount = %d, want 1", got.TrackCount)
|
||
}
|
||
}
|
||
|
||
func TestAppendTracks_OwnerOnly(t *testing.T) {
|
||
pool := newPool(t)
|
||
alice := seedUser(t, pool, "alice")
|
||
bob := seedUser(t, pool, "bob")
|
||
track := seedTrack(t, pool, "T", "A")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
pl, _ := svc.Create(context.Background(), alice.ID, "Mine", "", true)
|
||
err := svc.AppendTracks(context.Background(), bob.ID, pl.ID, []pgtype.UUID{track.ID})
|
||
if !errors.Is(err, playlists.ErrForbidden) {
|
||
t.Errorf("err = %v, want ErrForbidden", err)
|
||
}
|
||
}
|
||
|
||
func TestAppendTracks_NonExistentTrack(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||
err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{randomUUID()})
|
||
if !errors.Is(err, playlists.ErrTrackNotFound) {
|
||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||
}
|
||
}
|
||
|
||
func TestRemoveTrack_RenumbersPositions(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
t1 := seedTrack(t, pool, "Track 1", "Artist")
|
||
t2 := seedTrack(t, pool, "Track 2", "Artist")
|
||
t3 := seedTrack(t, pool, "Track 3", "Artist")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{t1.ID, t2.ID, t3.ID})
|
||
|
||
// Remove the middle one (position 1).
|
||
if err := svc.RemoveTrack(context.Background(), user.ID, pl.ID, 1); err != nil {
|
||
t.Fatalf("RemoveTrack: %v", err)
|
||
}
|
||
|
||
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
|
||
if len(got.Tracks) != 2 {
|
||
t.Fatalf("Tracks length = %d, want 2", len(got.Tracks))
|
||
}
|
||
if got.Tracks[0].Title != "Track 1" {
|
||
t.Errorf("position 0 = %q, want Track 1", got.Tracks[0].Title)
|
||
}
|
||
if got.Tracks[1].Title != "Track 3" {
|
||
t.Errorf("position 1 = %q, want Track 3 (renumbered from 2)", got.Tracks[1].Title)
|
||
}
|
||
}
|
||
|
||
func TestReorder_Permutation(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
t1 := seedTrack(t, pool, "A", "Artist")
|
||
t2 := seedTrack(t, pool, "B", "Artist")
|
||
t3 := seedTrack(t, pool, "C", "Artist")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{t1.ID, t2.ID, t3.ID})
|
||
|
||
// Reverse order: send [2, 1, 0].
|
||
if err := svc.Reorder(context.Background(), user.ID, pl.ID, []int32{2, 1, 0}); err != nil {
|
||
t.Fatalf("Reorder: %v", err)
|
||
}
|
||
|
||
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
|
||
wantOrder := []string{"C", "B", "A"}
|
||
for i, t := range got.Tracks {
|
||
if t.Title != wantOrder[i] {
|
||
t.Errorf("position %d = %q, want %q", i, t.Title, wantOrder[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestReorder_RejectsNonPermutation(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
tk := seedTrack(t, pool, "T", "A")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})
|
||
|
||
// 2 positions for a 1-track playlist — invalid.
|
||
err := svc.Reorder(context.Background(), user.ID, pl.ID, []int32{0, 1})
|
||
if !errors.Is(err, playlists.ErrInvalidInput) {
|
||
t.Errorf("err = %v, want ErrInvalidInput (length mismatch)", err)
|
||
}
|
||
|
||
// duplicate position — invalid.
|
||
pl2, _ := svc.Create(context.Background(), user.ID, "Two", "", false)
|
||
t2 := seedTrack(t, pool, "T2", "A")
|
||
_ = svc.AppendTracks(context.Background(), user.ID, pl2.ID, []pgtype.UUID{tk.ID, t2.ID})
|
||
err = svc.Reorder(context.Background(), user.ID, pl2.ID, []int32{0, 0})
|
||
if !errors.Is(err, playlists.ErrInvalidInput) {
|
||
t.Errorf("err = %v, want ErrInvalidInput (duplicate)", err)
|
||
}
|
||
}
|
||
|
||
func TestSnapshotPersistsAfterUpstreamTrackDelete(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
tk := seedTrack(t, pool, "Doomed track", "Artist")
|
||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||
|
||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})
|
||
|
||
// Delete the track row directly (simulating Lidarr re-import / file removal).
|
||
if _, err := pool.Exec(context.Background(), `DELETE FROM tracks WHERE id = $1`, tk.ID); err != nil {
|
||
t.Fatalf("delete track: %v", err)
|
||
}
|
||
|
||
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
|
||
if len(got.Tracks) != 1 {
|
||
t.Fatalf("Tracks length = %d, want 1 (soft-mark not silent-cascade)", len(got.Tracks))
|
||
}
|
||
row := got.Tracks[0]
|
||
if row.TrackID != nil {
|
||
t.Error("TrackID should be nil after upstream delete")
|
||
}
|
||
if row.Title != "Doomed track" {
|
||
t.Errorf("Snapshot Title = %q, want Doomed track", row.Title)
|
||
}
|
||
}
|
||
```
|
||
|
||
`seedTrack(t, pool, title, artist)` creates a track + its album + its artist. Implement as a helper in `service_test_helpers_test.go` (or inline) that does the three INSERTs and returns a struct with the IDs. Match the existing project pattern from `internal/lidarrquarantine/service_test.go`.
|
||
|
||
- [ ] **Step 3.3: Commit (combined with Task 4 — see Task 4 step 4.4)**
|
||
|
||
This task's commit waits for Task 4 to land the `GenerateCollage` symbol that the service references. The implementer should keep the changes uncommitted until both tasks compile together, then commit as one atomic change in Task 4.
|
||
|
||
---
|
||
|
||
### Task 4 — Cover collage generator
|
||
|
||
**Files:**
|
||
- Create: `internal/playlists/collage.go`
|
||
- Create: `internal/playlists/collage_test.go`
|
||
|
||
- [ ] **Step 4.1: Write `internal/playlists/collage.go`**
|
||
|
||
```go
|
||
package playlists
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/draw"
|
||
"image/jpeg"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
_ "image/png" // for decoding existing PNG covers
|
||
_ "image/jpeg" // for decoding existing JPEG covers
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
const (
|
||
collageCellSize = 300 // px per cell, 2x2 → 600x600 output
|
||
collageOutputDim = collageCellSize * 2
|
||
collageQuality = 85
|
||
)
|
||
|
||
// GenerateCollage composes a 600x600 JPEG from the first 4 contributing
|
||
// tracks' album covers. Missing covers (no upstream album, empty
|
||
// cover_art_path, file unreadable) get the album-fallback glyph in their
|
||
// cell. Writes to <dataDir>/playlist_covers/<playlist_id>.jpg and
|
||
// updates playlists.cover_path. Returns the relative path written.
|
||
func GenerateCollage(ctx context.Context, pool *pgxpool.Pool, playlistID pgtype.UUID, dataDir string) (string, error) {
|
||
q := dbq.New(pool)
|
||
rows, err := q.ListAllPlaylistTracksForCollage(ctx, dbq.ListAllPlaylistTracksForCollageParams{
|
||
PlaylistID: playlistID,
|
||
Limit: 4,
|
||
})
|
||
if err != nil {
|
||
return "", fmt.Errorf("list collage tracks: %w", err)
|
||
}
|
||
|
||
if len(rows) == 0 {
|
||
// Empty playlist: clear cover_path and remove any stale file.
|
||
if err := q.SetPlaylistCover(ctx, dbq.SetPlaylistCoverParams{
|
||
ID: playlistID,
|
||
CoverPath: nil,
|
||
}); err != nil {
|
||
return "", fmt.Errorf("clear cover_path: %w", err)
|
||
}
|
||
_ = os.Remove(filepath.Join(dataDir, "playlist_covers", uuidToFilename(playlistID)+".jpg"))
|
||
return "", nil
|
||
}
|
||
|
||
out := image.NewRGBA(image.Rect(0, 0, collageOutputDim, collageOutputDim))
|
||
// Fill with FabledSword `iron` (#1E2228) so any drawing gaps look intentional.
|
||
draw.Draw(out, out.Bounds(), &image.Uniform{C: color.RGBA{0x1E, 0x22, 0x28, 0xFF}}, image.Point{}, draw.Src)
|
||
|
||
for cell := 0; cell < 4; cell++ {
|
||
var coverPath string
|
||
if cell < len(rows) && rows[cell].AlbumCoverPath != nil {
|
||
coverPath = *rows[cell].AlbumCoverPath
|
||
}
|
||
img := loadCellImage(coverPath, dataDir)
|
||
// Place the cell. Cell coordinates: top-left (0,0), top-right (1,0), bottom-left (0,1), bottom-right (1,1).
|
||
col := cell % 2
|
||
row := cell / 2
|
||
dest := image.Rect(col*collageCellSize, row*collageCellSize, (col+1)*collageCellSize, (row+1)*collageCellSize)
|
||
drawScaled(out, dest, img)
|
||
}
|
||
|
||
// Encode + write.
|
||
if err := os.MkdirAll(filepath.Join(dataDir, "playlist_covers"), 0o755); err != nil {
|
||
return "", fmt.Errorf("mkdir playlist_covers: %w", err)
|
||
}
|
||
relPath := filepath.Join("playlist_covers", uuidToFilename(playlistID)+".jpg")
|
||
full := filepath.Join(dataDir, relPath)
|
||
tmp := full + ".tmp"
|
||
f, err := os.Create(tmp)
|
||
if err != nil {
|
||
return "", fmt.Errorf("create collage file: %w", err)
|
||
}
|
||
if err := jpeg.Encode(f, out, &jpeg.Options{Quality: collageQuality}); err != nil {
|
||
_ = f.Close()
|
||
_ = os.Remove(tmp)
|
||
return "", fmt.Errorf("encode jpeg: %w", err)
|
||
}
|
||
if err := f.Close(); err != nil {
|
||
return "", fmt.Errorf("close collage file: %w", err)
|
||
}
|
||
if err := os.Rename(tmp, full); err != nil {
|
||
return "", fmt.Errorf("rename: %w", err)
|
||
}
|
||
|
||
if err := q.SetPlaylistCover(ctx, dbq.SetPlaylistCoverParams{
|
||
ID: playlistID,
|
||
CoverPath: stringPtr(relPath),
|
||
}); err != nil {
|
||
return "", fmt.Errorf("set cover_path: %w", err)
|
||
}
|
||
return relPath, nil
|
||
}
|
||
|
||
// loadCellImage tries to load the album cover at the given path. On any
|
||
// failure (empty path, missing file, decode error), returns the rendered
|
||
// fallback glyph as a 300x300 image.
|
||
func loadCellImage(coverPath, dataDir string) image.Image {
|
||
if coverPath == "" {
|
||
return fallbackGlyph()
|
||
}
|
||
full := coverPath
|
||
if !filepath.IsAbs(coverPath) {
|
||
full = filepath.Join(dataDir, coverPath)
|
||
}
|
||
f, err := os.Open(full)
|
||
if err != nil {
|
||
return fallbackGlyph()
|
||
}
|
||
defer f.Close()
|
||
img, _, err := image.Decode(f)
|
||
if err != nil {
|
||
return fallbackGlyph()
|
||
}
|
||
return img
|
||
}
|
||
|
||
// fallbackGlyph returns a 300x300 image filled with the FabledSword
|
||
// "slate" surface tint and a centered solid box approximating the
|
||
// album-fallback glyph. Slice 1 trade-off: rasterizing the actual SVG
|
||
// at runtime requires a third-party SVG renderer (oksvg / etc.) and
|
||
// adds dependency + complexity. The solid placeholder reads as
|
||
// "this cell intentionally empty" without committing to SVG plumbing.
|
||
// A follow-up task can swap in real SVG rasterization.
|
||
func fallbackGlyph() image.Image {
|
||
img := image.NewRGBA(image.Rect(0, 0, collageCellSize, collageCellSize))
|
||
bg := color.RGBA{0x2C, 0x31, 0x3A, 0xFF} // FabledSword slate
|
||
fg := color.RGBA{0x9C, 0x9A, 0x92, 0xFF} // FabledSword ash
|
||
draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
|
||
// Center 100x100 box as a placeholder mark.
|
||
center := image.Rect(100, 100, 200, 200)
|
||
draw.Draw(img, center, &image.Uniform{C: fg}, image.Point{}, draw.Src)
|
||
return img
|
||
}
|
||
|
||
// drawScaled copies src into dst.Rect, scaling with simple nearest-neighbor.
|
||
// stdlib lacks high-quality scaling; nearest-neighbor is fine for a
|
||
// 600x600 output where each cell is 300x300 — most album covers are
|
||
// already 300-1500 pixels and the visual loss is minor.
|
||
func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) {
|
||
srcBounds := src.Bounds()
|
||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||
for x := r.Min.X; x < r.Max.X; x++ {
|
||
sx := srcBounds.Min.X + (x-r.Min.X)*srcBounds.Dx()/r.Dx()
|
||
sy := srcBounds.Min.Y + (y-r.Min.Y)*srcBounds.Dy()/r.Dy()
|
||
dst.Set(x, y, src.At(sx, sy))
|
||
}
|
||
}
|
||
}
|
||
|
||
func stringPtr(s string) *string { return &s }
|
||
|
||
func uuidToFilename(u pgtype.UUID) string {
|
||
// Format the UUID with hyphens for human readability of the file
|
||
// name, but strip any path-traversal characters defensively.
|
||
s := pgtypeUUIDString(u)
|
||
return strings.NewReplacer("/", "_", "..", "_").Replace(s)
|
||
}
|
||
|
||
func pgtypeUUIDString(u pgtype.UUID) string {
|
||
if !u.Valid {
|
||
return ""
|
||
}
|
||
b := u.Bytes
|
||
return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
|
||
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
|
||
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15])
|
||
}
|
||
```
|
||
|
||
The fallback glyph is a solid placeholder rather than rasterized SVG. This is a deliberate slice-1 trade-off documented in the function's doc comment. A follow-up task can wire in the actual `album-fallback.svg`.
|
||
|
||
If the project already has a UUID-to-string helper somewhere (e.g., `uuidToString` in `internal/api/convert.go`), reuse it instead of `pgtypeUUIDString`. Read `convert.go` first.
|
||
|
||
If `dbq.ListAllPlaylistTracksForCollageRow` exposes `AlbumCoverPath` as `pgtype.Text` rather than `*string`, adapt the conditional accordingly.
|
||
|
||
- [ ] **Step 4.2: Write `internal/playlists/collage_test.go`**
|
||
|
||
```go
|
||
package playlists_test
|
||
|
||
import (
|
||
"context"
|
||
"image"
|
||
_ "image/jpeg"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||
)
|
||
|
||
func TestGenerateCollage_EmptyPlaylist(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
dir := t.TempDir()
|
||
svc := playlists.NewService(pool, nil, dir)
|
||
|
||
pl, _ := svc.Create(context.Background(), user.ID, "Empty", "", false)
|
||
|
||
relPath, err := playlists.GenerateCollage(context.Background(), pool, pl.ID, dir)
|
||
if err != nil {
|
||
t.Fatalf("GenerateCollage: %v", err)
|
||
}
|
||
if relPath != "" {
|
||
t.Errorf("relPath = %q, want \"\" for empty playlist", relPath)
|
||
}
|
||
}
|
||
|
||
func TestGenerateCollage_OneTrack(t *testing.T) {
|
||
pool := newPool(t)
|
||
user := seedUser(t, pool, "alice")
|
||
tk := seedTrack(t, pool, "Roygbiv", "BoC")
|
||
dir := t.TempDir()
|
||
svc := playlists.NewService(pool, nil, dir)
|
||
|
||
pl, _ := svc.Create(context.Background(), user.ID, "One", "", false)
|
||
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})
|
||
|
||
// Append already triggers GenerateCollage; verify the file landed.
|
||
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
|
||
if got.CoverPath == nil || *got.CoverPath == "" {
|
||
t.Fatalf("CoverPath empty after append")
|
||
}
|
||
|
||
full := filepath.Join(dir, *got.CoverPath)
|
||
f, err := os.Open(full)
|
||
if err != nil {
|
||
t.Fatalf("open collage: %v", err)
|
||
}
|
||
defer f.Close()
|
||
img, format, err := image.Decode(f)
|
||
if err != nil {
|
||
t.Fatalf("decode collage: %v", err)
|
||
}
|
||
if format != "jpeg" {
|
||
t.Errorf("format = %q, want jpeg", format)
|
||
}
|
||
if img.Bounds().Dx() != 600 || img.Bounds().Dy() != 600 {
|
||
t.Errorf("dimensions = %dx%d, want 600x600", img.Bounds().Dx(), img.Bounds().Dy())
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.3: Wire `os.MkdirAll` import**
|
||
|
||
Already in step 4.1.
|
||
|
||
- [ ] **Step 4.4: Commit Task 3 + Task 4 together**
|
||
|
||
```bash
|
||
git add internal/playlists/service.go internal/playlists/service_test.go internal/playlists/collage.go internal/playlists/collage_test.go
|
||
git commit -F - <<'EOF'
|
||
feat(playlists): track operations + cover-collage generator
|
||
|
||
AppendTracks / RemoveTrack / Reorder run in single transactions and
|
||
update the denormalized rollups (track_count, duration_sec). Reorder
|
||
uses a +10000 offset to bump every row out of the position range
|
||
before writing the new positions, sidestepping PK conflicts during
|
||
rewrite.
|
||
|
||
GenerateCollage composes a 600x600 JPEG from the first 4 album
|
||
covers, with a slate-tinted fallback for missing cells. Synchronous,
|
||
called inline after every mutating operation. SVG-rasterized fallback
|
||
is a follow-up — slice 1 ships with a solid placeholder.
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5 — API handlers + service wiring
|
||
|
||
**Files:**
|
||
- Create: `internal/api/playlists.go`
|
||
- Create: `internal/api/playlists_test.go`
|
||
- Modify: `internal/api/api.go` — `Mount` gains `playlistsSvc *playlists.Service`; register 9 routes
|
||
- Modify: `internal/server/server.go` — construct `playlistsSvc`; pass to `Mount`
|
||
|
||
- [ ] **Step 5.1: Write `internal/api/playlists.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"path/filepath"
|
||
"strconv"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||
)
|
||
|
||
// --- wire shapes ---
|
||
|
||
type playlistRowView struct {
|
||
ID string `json:"id"`
|
||
UserID string `json:"user_id"`
|
||
OwnerUsername string `json:"owner_username"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
IsPublic bool `json:"is_public"`
|
||
CoverURL string `json:"cover_url"`
|
||
TrackCount int32 `json:"track_count"`
|
||
DurationSec int32 `json:"duration_sec"`
|
||
CreatedAt string `json:"created_at"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
}
|
||
|
||
type playlistTrackView struct {
|
||
Position int32 `json:"position"`
|
||
TrackID *string `json:"track_id"`
|
||
AlbumID *string `json:"album_id"`
|
||
ArtistID *string `json:"artist_id"`
|
||
Title string `json:"title"`
|
||
ArtistName string `json:"artist_name"`
|
||
AlbumTitle string `json:"album_title"`
|
||
DurationSec int32 `json:"duration_sec"`
|
||
StreamURL *string `json:"stream_url"`
|
||
AddedAt string `json:"added_at"`
|
||
}
|
||
|
||
type playlistDetailView struct {
|
||
playlistRowView
|
||
Tracks []playlistTrackView `json:"tracks"`
|
||
}
|
||
|
||
type listPlaylistsResponse struct {
|
||
Owned []playlistRowView `json:"owned"`
|
||
Public []playlistRowView `json:"public"`
|
||
}
|
||
|
||
// --- handlers ---
|
||
|
||
// GET /api/playlists
|
||
func (h *handlers) handleListPlaylists(w http.ResponseWriter, r *http.Request) {
|
||
caller, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||
return
|
||
}
|
||
rows, err := h.playlists.List(r.Context(), caller.ID)
|
||
if err != nil {
|
||
h.logger.Error("api: list playlists failed", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
resp := listPlaylistsResponse{Owned: []playlistRowView{}, Public: []playlistRowView{}}
|
||
for _, r := range rows {
|
||
v := playlistRowToView(&r)
|
||
if pgtypeUUIDEqual(r.UserID, caller.ID) {
|
||
resp.Owned = append(resp.Owned, v)
|
||
} else {
|
||
resp.Public = append(resp.Public, v)
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
type createPlaylistBody struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
IsPublic bool `json:"is_public"`
|
||
}
|
||
|
||
// POST /api/playlists
|
||
func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) {
|
||
caller, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||
return
|
||
}
|
||
var body createPlaylistBody
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||
return
|
||
}
|
||
row, err := h.playlists.Create(r.Context(), caller.ID, body.Name, body.Description, body.IsPublic)
|
||
if err != nil {
|
||
writePlaylistErr(w, err, "create")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
||
}
|
||
|
||
// GET /api/playlists/{id}
|
||
func (h *handlers) handleGetPlaylist(w http.ResponseWriter, r *http.Request) {
|
||
caller, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||
return
|
||
}
|
||
playlistID, err := parseUUIDParam(r, "id")
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||
return
|
||
}
|
||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||
if err != nil {
|
||
writePlaylistErr(w, err, "get")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||
}
|
||
|
||
type updatePlaylistBody struct {
|
||
Name *string `json:"name,omitempty"`
|
||
Description *string `json:"description,omitempty"`
|
||
IsPublic *bool `json:"is_public,omitempty"`
|
||
}
|
||
|
||
// PATCH /api/playlists/{id}
|
||
func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) {
|
||
caller, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||
return
|
||
}
|
||
playlistID, err := parseUUIDParam(r, "id")
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||
return
|
||
}
|
||
var body updatePlaylistBody
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||
return
|
||
}
|
||
row, err := h.playlists.Update(r.Context(), caller.ID, playlistID, playlists.UpdateInput{
|
||
Name: body.Name,
|
||
Description: body.Description,
|
||
IsPublic: body.IsPublic,
|
||
})
|
||
if err != nil {
|
||
writePlaylistErr(w, err, "update")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, playlistRowToView(row))
|
||
}
|
||
|
||
// DELETE /api/playlists/{id}
|
||
func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) {
|
||
caller, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||
return
|
||
}
|
||
playlistID, err := parseUUIDParam(r, "id")
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||
return
|
||
}
|
||
if err := h.playlists.Delete(r.Context(), caller.ID, playlistID); err != nil {
|
||
writePlaylistErr(w, err, "delete")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
type appendTracksBody struct {
|
||
TrackIDs []string `json:"track_ids"`
|
||
}
|
||
|
||
// POST /api/playlists/{id}/tracks
|
||
func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) {
|
||
caller, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||
return
|
||
}
|
||
playlistID, err := parseUUIDParam(r, "id")
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||
return
|
||
}
|
||
var body appendTracksBody
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||
return
|
||
}
|
||
trackIDs := make([]pgtype.UUID, 0, len(body.TrackIDs))
|
||
for _, s := range body.TrackIDs {
|
||
u, ok := parseUUID(s)
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("invalid track id %q", s))
|
||
return
|
||
}
|
||
trackIDs = append(trackIDs, u)
|
||
}
|
||
if err := h.playlists.AppendTracks(r.Context(), caller.ID, playlistID, trackIDs); err != nil {
|
||
writePlaylistErr(w, err, "append tracks")
|
||
return
|
||
}
|
||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||
if err != nil {
|
||
writePlaylistErr(w, err, "get-after-append")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||
}
|
||
|
||
// DELETE /api/playlists/{id}/tracks/{position}
|
||
func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Request) {
|
||
caller, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||
return
|
||
}
|
||
playlistID, err := parseUUIDParam(r, "id")
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||
return
|
||
}
|
||
posStr := chi.URLParam(r, "position")
|
||
pos64, err := strconv.ParseInt(posStr, 10, 32)
|
||
if err != nil || pos64 < 0 {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid position")
|
||
return
|
||
}
|
||
if err := h.playlists.RemoveTrack(r.Context(), caller.ID, playlistID, int32(pos64)); err != nil {
|
||
writePlaylistErr(w, err, "remove track")
|
||
return
|
||
}
|
||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||
if err != nil {
|
||
writePlaylistErr(w, err, "get-after-remove")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||
}
|
||
|
||
type reorderTracksBody struct {
|
||
OrderedPositions []int32 `json:"ordered_positions"`
|
||
}
|
||
|
||
// PUT /api/playlists/{id}/tracks
|
||
func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request) {
|
||
caller, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||
return
|
||
}
|
||
playlistID, err := parseUUIDParam(r, "id")
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||
return
|
||
}
|
||
var body reorderTracksBody
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
||
return
|
||
}
|
||
if err := h.playlists.Reorder(r.Context(), caller.ID, playlistID, body.OrderedPositions); err != nil {
|
||
writePlaylistErr(w, err, "reorder")
|
||
return
|
||
}
|
||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||
if err != nil {
|
||
writePlaylistErr(w, err, "get-after-reorder")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
|
||
}
|
||
|
||
// GET /api/playlists/{id}/cover
|
||
func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request) {
|
||
caller, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
|
||
return
|
||
}
|
||
playlistID, err := parseUUIDParam(r, "id")
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
|
||
return
|
||
}
|
||
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
|
||
if err != nil {
|
||
writePlaylistErr(w, err, "get cover")
|
||
return
|
||
}
|
||
if detail.CoverPath == nil || *detail.CoverPath == "" {
|
||
writeErr(w, http.StatusNotFound, "not_found", "no cover")
|
||
return
|
||
}
|
||
full := filepath.Join(h.dataDir, *detail.CoverPath)
|
||
http.ServeFile(w, r, full)
|
||
}
|
||
|
||
// --- helpers ---
|
||
|
||
func writePlaylistErr(w http.ResponseWriter, err error, op string) {
|
||
switch {
|
||
case errors.Is(err, playlists.ErrNotFound):
|
||
writeErr(w, http.StatusNotFound, "not_found", "playlist not found")
|
||
case errors.Is(err, playlists.ErrForbidden):
|
||
writeErr(w, http.StatusForbidden, "not_authorized", "you don't own this playlist")
|
||
case errors.Is(err, playlists.ErrInvalidInput):
|
||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||
case errors.Is(err, playlists.ErrTrackNotFound):
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "one of the supplied track ids does not exist")
|
||
default:
|
||
writeErr(w, http.StatusInternalServerError, "server_error", op+" failed")
|
||
}
|
||
}
|
||
|
||
func playlistRowToView(r *playlists.PlaylistRow) playlistRowView {
|
||
v := playlistRowView{
|
||
ID: uuidToString(r.ID),
|
||
UserID: uuidToString(r.UserID),
|
||
OwnerUsername: r.OwnerUsername,
|
||
Name: r.Name,
|
||
Description: r.Description,
|
||
IsPublic: r.IsPublic,
|
||
TrackCount: r.TrackCount,
|
||
DurationSec: r.DurationSec,
|
||
CreatedAt: r.CreatedAt.Time.Format(timeFormat),
|
||
UpdatedAt: r.UpdatedAt.Time.Format(timeFormat),
|
||
}
|
||
if r.CoverPath != nil && *r.CoverPath != "" {
|
||
v.CoverURL = "/api/playlists/" + v.ID + "/cover"
|
||
}
|
||
return v
|
||
}
|
||
|
||
func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView {
|
||
out := playlistDetailView{
|
||
playlistRowView: playlistRowToView(&d.PlaylistRow),
|
||
Tracks: make([]playlistTrackView, 0, len(d.Tracks)),
|
||
}
|
||
for _, t := range d.Tracks {
|
||
v := playlistTrackView{
|
||
Position: t.Position,
|
||
Title: t.Title,
|
||
ArtistName: t.ArtistName,
|
||
AlbumTitle: t.AlbumTitle,
|
||
DurationSec: t.DurationSec,
|
||
AddedAt: t.AddedAt.Time.Format(timeFormat),
|
||
}
|
||
if t.TrackID != nil {
|
||
s := uuidToString(*t.TrackID)
|
||
v.TrackID = &s
|
||
streamURL := "/api/tracks/" + s + "/stream"
|
||
v.StreamURL = &streamURL
|
||
}
|
||
if t.AlbumID != nil {
|
||
s := uuidToString(*t.AlbumID)
|
||
v.AlbumID = &s
|
||
}
|
||
if t.ArtistID != nil {
|
||
s := uuidToString(*t.ArtistID)
|
||
v.ArtistID = &s
|
||
}
|
||
out.Tracks = append(out.Tracks, v)
|
||
}
|
||
return out
|
||
}
|
||
```
|
||
|
||
Read `internal/api/convert.go` to confirm the names and signatures of `uuidToString`, `parseUUID`, `parseUUIDParam`, `pgtypeUUIDEqual`, `timeFormat`, `writeErr`, `writeJSON`. Adapt if any differ from the placeholders above. The `h.dataDir` field is added in step 5.4 below.
|
||
|
||
- [ ] **Step 5.2: Write `internal/api/playlists_test.go`**
|
||
|
||
```go
|
||
package api_test
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestPlaylists_CreateThenGet(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
user := seedAdminUser(t, srv.pool) // any authenticated user works for non-admin endpoints
|
||
|
||
body := strings.NewReader(`{"name":"Saturday morning","description":"Mellow","is_public":false}`)
|
||
req := httptest.NewRequest(http.MethodPost, "/api/playlists", body)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req = withSession(req, user)
|
||
rr := httptest.NewRecorder()
|
||
srv.handler.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("create status = %d, body = %s", rr.Code, rr.Body.String())
|
||
}
|
||
var created struct {
|
||
ID string `json:"id"`
|
||
}
|
||
_ = json.Unmarshal(rr.Body.Bytes(), &created)
|
||
if created.ID == "" {
|
||
t.Fatal("created.id empty")
|
||
}
|
||
|
||
// Get by id.
|
||
req2 := httptest.NewRequest(http.MethodGet, "/api/playlists/"+created.ID, nil)
|
||
req2 = withSession(req2, user)
|
||
rr2 := httptest.NewRecorder()
|
||
srv.handler.ServeHTTP(rr2, req2)
|
||
if rr2.Code != http.StatusOK {
|
||
t.Fatalf("get status = %d, body = %s", rr2.Code, rr2.Body.String())
|
||
}
|
||
if !strings.Contains(rr2.Body.String(), `"name":"Saturday morning"`) {
|
||
t.Errorf("get body missing name: %s", rr2.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestPlaylists_PatchOwnerOnly(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
alice := seedUser(t, srv.pool, "alice")
|
||
bob := seedUser(t, srv.pool, "bob")
|
||
|
||
create := strings.NewReader(`{"name":"Mine","is_public":true}`)
|
||
req := httptest.NewRequest(http.MethodPost, "/api/playlists", create)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req = withSession(req, alice)
|
||
rr := httptest.NewRecorder()
|
||
srv.handler.ServeHTTP(rr, req)
|
||
if rr.Code != http.StatusOK {
|
||
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
|
||
}
|
||
var created struct{ ID string }
|
||
_ = json.Unmarshal(rr.Body.Bytes(), &created)
|
||
|
||
// Bob tries to PATCH alice's playlist.
|
||
patchBody := strings.NewReader(`{"name":"Hijacked"}`)
|
||
pr := httptest.NewRequest(http.MethodPatch, "/api/playlists/"+created.ID, patchBody)
|
||
pr.Header.Set("Content-Type", "application/json")
|
||
pr = withSession(pr, bob)
|
||
prr := httptest.NewRecorder()
|
||
srv.handler.ServeHTTP(prr, pr)
|
||
if prr.Code != http.StatusForbidden {
|
||
t.Errorf("non-owner patch status = %d, want 403; body = %s", prr.Code, prr.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestPlaylists_AppendThenReorder(t *testing.T) {
|
||
srv, _ := newTestServer(t)
|
||
user := seedUser(t, srv.pool, "alice")
|
||
t1 := seedTrack(t, srv.pool, "Track 1", "Artist")
|
||
t2 := seedTrack(t, srv.pool, "Track 2", "Artist")
|
||
t3 := seedTrack(t, srv.pool, "Track 3", "Artist")
|
||
|
||
// Create.
|
||
req := httptest.NewRequest(http.MethodPost, "/api/playlists", strings.NewReader(`{"name":"R"}`))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req = withSession(req, user)
|
||
rr := httptest.NewRecorder()
|
||
srv.handler.ServeHTTP(rr, req)
|
||
var created struct{ ID string }
|
||
_ = json.Unmarshal(rr.Body.Bytes(), &created)
|
||
|
||
// Append 3 tracks.
|
||
appendBody, _ := json.Marshal(map[string]any{"track_ids": []string{
|
||
uuidString(t1.ID), uuidString(t2.ID), uuidString(t3.ID),
|
||
}})
|
||
ar := httptest.NewRequest(http.MethodPost, "/api/playlists/"+created.ID+"/tracks", bytes.NewReader(appendBody))
|
||
ar.Header.Set("Content-Type", "application/json")
|
||
ar = withSession(ar, user)
|
||
arr := httptest.NewRecorder()
|
||
srv.handler.ServeHTTP(arr, ar)
|
||
if arr.Code != http.StatusOK {
|
||
t.Fatalf("append: %d %s", arr.Code, arr.Body.String())
|
||
}
|
||
|
||
// Reorder: reverse.
|
||
reorderBody := strings.NewReader(`{"ordered_positions":[2,1,0]}`)
|
||
rrq := httptest.NewRequest(http.MethodPut, "/api/playlists/"+created.ID+"/tracks", reorderBody)
|
||
rrq.Header.Set("Content-Type", "application/json")
|
||
rrq = withSession(rrq, user)
|
||
rrr := httptest.NewRecorder()
|
||
srv.handler.ServeHTTP(rrr, rrq)
|
||
if rrr.Code != http.StatusOK {
|
||
t.Fatalf("reorder: %d %s", rrr.Code, rrr.Body.String())
|
||
}
|
||
|
||
// Verify by re-reading.
|
||
gr := httptest.NewRequest(http.MethodGet, "/api/playlists/"+created.ID, nil)
|
||
gr = withSession(gr, user)
|
||
grr := httptest.NewRecorder()
|
||
srv.handler.ServeHTTP(grr, gr)
|
||
body := grr.Body.String()
|
||
// Track 3 should now be at position 0; Track 1 at position 2.
|
||
pos3 := strings.Index(body, `"title":"Track 3"`)
|
||
pos1 := strings.Index(body, `"title":"Track 1"`)
|
||
if pos3 == -1 || pos1 == -1 || pos3 > pos1 {
|
||
t.Errorf("after reverse, Track 3 should appear before Track 1 in JSON; body = %s", body)
|
||
}
|
||
}
|
||
```
|
||
|
||
The `newTestServer`, `seedUser`, `seedAdminUser`, `withSession`, `seedTrack`, `uuidString` helpers exist or land in this slice's test fixtures (read `internal/api/admin_tracks_test.go` for the patterns). Match the existing harness.
|
||
|
||
- [ ] **Step 5.3: Modify `internal/api/api.go` — register routes + add service field**
|
||
|
||
Read `internal/api/api.go`. The `Mount` function takes a sequence of services. Add `playlistsSvc *playlists.Service` as the next parameter (after `tracksSvc *tracks.Service`). Add `playlists *playlists.Service` and `dataDir string` fields to the `handlers` struct. Pass them through in the constructor.
|
||
|
||
Then add the routes. Find the existing `authed.Group(...)` block and append:
|
||
|
||
```go
|
||
authed.Get("/playlists", h.handleListPlaylists)
|
||
authed.Post("/playlists", h.handleCreatePlaylist)
|
||
authed.Get("/playlists/{id}", h.handleGetPlaylist)
|
||
authed.Patch("/playlists/{id}", h.handleUpdatePlaylist)
|
||
authed.Delete("/playlists/{id}", h.handleDeletePlaylist)
|
||
authed.Post("/playlists/{id}/tracks", h.handleAppendTracks)
|
||
authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
|
||
authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist)
|
||
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
|
||
```
|
||
|
||
Add the import `"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"` to the file.
|
||
|
||
- [ ] **Step 5.4: Modify `internal/server/server.go` — construct service**
|
||
|
||
Find the `lidarrQuar` / `tracksSvc` construction block. Add:
|
||
|
||
```go
|
||
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
|
||
```
|
||
|
||
(`s.DataDir` is the existing field that points at the configured `data/` directory. If it's named differently — e.g., `s.Cfg.DataDir` — adapt accordingly. Read the file first.)
|
||
|
||
Pass `playlistsSvc` to `api.Mount(...)` as the new last argument.
|
||
|
||
Add the import `"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"`.
|
||
|
||
- [ ] **Step 5.5: Commit**
|
||
|
||
```bash
|
||
git add internal/api/playlists.go internal/api/playlists_test.go internal/api/api.go internal/server/server.go
|
||
git commit -F - <<'EOF'
|
||
feat(api): /api/playlists* handlers for M7 #352 slice 1
|
||
|
||
9 handlers covering create / get / list / update / delete / append /
|
||
remove / reorder / cover. Permissions enforced in the service layer
|
||
(ErrForbidden -> 403 not_authorized) so the handler is a thin
|
||
HTTP-shape adapter. /cover serves the cached collage from disk via
|
||
http.ServeFile; 404 when cover_path is nil.
|
||
|
||
Wire codes use the project's flat envelope: not_found, not_authorized,
|
||
unauthenticated, bad_request, server_error.
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6 — Frontend API helper + types + query keys
|
||
|
||
**Files:**
|
||
- Create: `web/src/lib/api/playlists.ts`
|
||
- Create: `web/src/lib/api/playlists.test.ts`
|
||
- Modify: `web/src/lib/api/types.ts` — add `Playlist`, `PlaylistDetail`, `PlaylistTrack`
|
||
- Modify: `web/src/lib/api/queries.ts` — add `qk.playlists()`, `qk.playlist(id)`
|
||
|
||
- [ ] **Step 6.1: Add types to `web/src/lib/api/types.ts`**
|
||
|
||
Append (or add near the existing entity types):
|
||
|
||
```ts
|
||
export type Playlist = {
|
||
id: string;
|
||
user_id: string;
|
||
owner_username: string;
|
||
name: string;
|
||
description: string;
|
||
is_public: boolean;
|
||
cover_url: string; // empty string when no cover; UI renders glyph
|
||
track_count: number;
|
||
duration_sec: number;
|
||
created_at: string;
|
||
updated_at: string;
|
||
};
|
||
|
||
export type PlaylistTrack = {
|
||
position: number;
|
||
track_id: string | null; // null when upstream track was removed
|
||
album_id: string | null;
|
||
artist_id: string | null;
|
||
title: string;
|
||
artist_name: string;
|
||
album_title: string;
|
||
duration_sec: number;
|
||
stream_url: string | null;
|
||
added_at: string;
|
||
};
|
||
|
||
export type PlaylistDetail = Playlist & {
|
||
tracks: PlaylistTrack[];
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 6.2: Add query keys to `web/src/lib/api/queries.ts`**
|
||
|
||
Find the `qk` namespace and append:
|
||
|
||
```ts
|
||
playlists: () => ['playlists'] as const,
|
||
playlist: (id: string) => ['playlist', id] as const,
|
||
```
|
||
|
||
- [ ] **Step 6.3: Write `web/src/lib/api/playlists.ts`**
|
||
|
||
```ts
|
||
import { createQuery } from '@tanstack/svelte-query';
|
||
import { apiFetch } from './client';
|
||
import { qk } from './queries';
|
||
import type { Playlist, PlaylistDetail } from './types';
|
||
|
||
type ListPlaylistsResponse = {
|
||
owned: Playlist[];
|
||
public: Playlist[];
|
||
};
|
||
|
||
export async function listPlaylists(): Promise<ListPlaylistsResponse> {
|
||
return (await apiFetch('/api/playlists', { method: 'GET' })) as ListPlaylistsResponse;
|
||
}
|
||
|
||
export async function getPlaylist(id: string): Promise<PlaylistDetail> {
|
||
return (await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { method: 'GET' })) as PlaylistDetail;
|
||
}
|
||
|
||
export type CreatePlaylistInput = {
|
||
name: string;
|
||
description?: string;
|
||
is_public?: boolean;
|
||
};
|
||
|
||
export async function createPlaylist(input: CreatePlaylistInput): Promise<Playlist> {
|
||
return (await apiFetch('/api/playlists', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(input)
|
||
})) as Playlist;
|
||
}
|
||
|
||
export type UpdatePlaylistInput = {
|
||
name?: string;
|
||
description?: string;
|
||
is_public?: boolean;
|
||
};
|
||
|
||
export async function updatePlaylist(id: string, input: UpdatePlaylistInput): Promise<Playlist> {
|
||
return (await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(input)
|
||
})) as Playlist;
|
||
}
|
||
|
||
export async function deletePlaylist(id: string): Promise<void> {
|
||
await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||
}
|
||
|
||
export async function appendTracks(playlistID: string, trackIDs: string[]): Promise<PlaylistDetail> {
|
||
return (await apiFetch(`/api/playlists/${encodeURIComponent(playlistID)}/tracks`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ track_ids: trackIDs })
|
||
})) as PlaylistDetail;
|
||
}
|
||
|
||
export async function removePlaylistTrack(playlistID: string, position: number): Promise<PlaylistDetail> {
|
||
return (await apiFetch(
|
||
`/api/playlists/${encodeURIComponent(playlistID)}/tracks/${position}`,
|
||
{ method: 'DELETE' }
|
||
)) as PlaylistDetail;
|
||
}
|
||
|
||
export async function reorderPlaylist(playlistID: string, orderedPositions: number[]): Promise<PlaylistDetail> {
|
||
return (await apiFetch(`/api/playlists/${encodeURIComponent(playlistID)}/tracks`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ordered_positions: orderedPositions })
|
||
})) as PlaylistDetail;
|
||
}
|
||
|
||
export function createPlaylistsQuery() {
|
||
return createQuery({
|
||
queryKey: qk.playlists(),
|
||
queryFn: listPlaylists,
|
||
staleTime: 30_000
|
||
});
|
||
}
|
||
|
||
export function createPlaylistQuery(id: string) {
|
||
return createQuery({
|
||
queryKey: qk.playlist(id),
|
||
queryFn: () => getPlaylist(id),
|
||
staleTime: 30_000
|
||
});
|
||
}
|
||
```
|
||
|
||
Read `web/src/lib/api/client.ts` to confirm `apiFetch`'s return type (likely `Promise<unknown>`) and adjust the casts. The `as Playlist` etc. casts mirror the project convention you can see in `web/src/lib/api/admin/tracks.ts` (Task 4 of #372).
|
||
|
||
- [ ] **Step 6.4: Write `web/src/lib/api/playlists.test.ts`**
|
||
|
||
```ts
|
||
import { describe, expect, test, afterEach, vi } from 'vitest';
|
||
import {
|
||
listPlaylists,
|
||
getPlaylist,
|
||
createPlaylist,
|
||
updatePlaylist,
|
||
deletePlaylist,
|
||
appendTracks,
|
||
removePlaylistTrack,
|
||
reorderPlaylist
|
||
} from './playlists';
|
||
|
||
function stubFetch(status: number, body: unknown) {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () =>
|
||
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
||
status,
|
||
headers: { 'Content-Type': 'application/json' }
|
||
})
|
||
)
|
||
);
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
describe('playlists API helper', () => {
|
||
test('listPlaylists GETs /api/playlists', async () => {
|
||
stubFetch(200, { owned: [], public: [] });
|
||
const r = await listPlaylists();
|
||
expect(r.owned).toEqual([]);
|
||
expect(r.public).toEqual([]);
|
||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||
expect(call[0]).toBe('/api/playlists');
|
||
expect(call[1].method).toBe('GET');
|
||
});
|
||
|
||
test('createPlaylist POSTs JSON body', async () => {
|
||
stubFetch(200, { id: 'p1', name: 'Test' });
|
||
const r = await createPlaylist({ name: 'Test' });
|
||
expect(r.id).toBe('p1');
|
||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||
expect(call[1].method).toBe('POST');
|
||
expect(JSON.parse(call[1].body as string)).toEqual({ name: 'Test' });
|
||
});
|
||
|
||
test('reorderPlaylist PUTs ordered_positions', async () => {
|
||
stubFetch(200, { id: 'p1', tracks: [] });
|
||
await reorderPlaylist('p1', [2, 1, 0]);
|
||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||
expect(call[0]).toBe('/api/playlists/p1/tracks');
|
||
expect(call[1].method).toBe('PUT');
|
||
expect(JSON.parse(call[1].body as string)).toEqual({ ordered_positions: [2, 1, 0] });
|
||
});
|
||
|
||
test('removePlaylistTrack DELETEs by position', async () => {
|
||
stubFetch(200, { id: 'p1', tracks: [] });
|
||
await removePlaylistTrack('p1', 3);
|
||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||
expect(call[0]).toBe('/api/playlists/p1/tracks/3');
|
||
expect(call[1].method).toBe('DELETE');
|
||
});
|
||
|
||
test('not_found surfaces as ApiError', async () => {
|
||
stubFetch(404, { error: 'not_found' });
|
||
await expect(getPlaylist('missing')).rejects.toMatchObject({ code: 'not_found' });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6.5: Commit**
|
||
|
||
```bash
|
||
git add web/src/lib/api/playlists.ts web/src/lib/api/playlists.test.ts web/src/lib/api/types.ts web/src/lib/api/queries.ts
|
||
git commit -F - <<'EOF'
|
||
feat(web/api): playlists helper + types + query keys (M7 #352 slice 1)
|
||
|
||
Wire shapes mirror the server's snake_case envelope. URL helpers use
|
||
the existing apiFetch + ApiError surface so error codes (not_found,
|
||
not_authorized, bad_request, server_error) propagate via the same
|
||
copyForCode chain as everything else.
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7 — `<PlaylistCard>` component
|
||
|
||
**Files:**
|
||
- Create: `web/src/lib/components/PlaylistCard.svelte`
|
||
- Create: `web/src/lib/components/PlaylistCard.test.ts`
|
||
|
||
- [ ] **Step 7.1: Write `web/src/lib/components/PlaylistCard.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import type { Playlist } from '$lib/api/types';
|
||
import { user } from '$lib/auth/store.svelte';
|
||
|
||
let { playlist }: { playlist: Playlist } = $props();
|
||
|
||
const isOwn = $derived(user.value?.id === playlist.user_id);
|
||
</script>
|
||
|
||
<button
|
||
type="button"
|
||
onclick={() => goto(`/playlists/${playlist.id}`)}
|
||
class="group flex w-full flex-col items-start gap-2 rounded-md p-2 text-left hover:bg-surface-hover"
|
||
>
|
||
<div class="aspect-square w-full overflow-hidden rounded-md bg-surface-secondary">
|
||
{#if playlist.cover_url}
|
||
<img
|
||
src={playlist.cover_url}
|
||
alt=""
|
||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||
/>
|
||
{:else}
|
||
<div class="flex h-full w-full items-center justify-center text-text-muted">
|
||
<span class="text-xs">No tracks yet</span>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<div class="w-full min-w-0">
|
||
<div class="truncate text-sm font-medium text-text-primary">{playlist.name}</div>
|
||
<div class="truncate text-xs text-text-muted">
|
||
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}
|
||
{#if !isOwn}
|
||
· by {playlist.owner_username}
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
```
|
||
|
||
If the FabledSword class names differ (e.g., `bg-surface-secondary` vs. `bg-slate`), adapt to whatever AlbumCard uses. Read `web/src/lib/components/AlbumCard.svelte` for the canonical card pattern + class names.
|
||
|
||
- [ ] **Step 7.2: Write `web/src/lib/components/PlaylistCard.test.ts`**
|
||
|
||
```ts
|
||
import { describe, expect, test } from 'vitest';
|
||
import { render, screen } from '@testing-library/svelte';
|
||
import { writable } from 'svelte/store';
|
||
import { vi } from 'vitest';
|
||
import PlaylistCard from './PlaylistCard.svelte';
|
||
import type { Playlist } from '$lib/api/types';
|
||
|
||
vi.mock('$lib/auth/store.svelte', () => ({
|
||
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
|
||
}));
|
||
|
||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||
|
||
const base: Playlist = {
|
||
id: 'p-1',
|
||
user_id: 'u-self',
|
||
owner_username: 'me',
|
||
name: 'Saturday morning',
|
||
description: '',
|
||
is_public: false,
|
||
cover_url: '',
|
||
track_count: 12,
|
||
duration_sec: 0,
|
||
created_at: '',
|
||
updated_at: ''
|
||
};
|
||
|
||
describe('PlaylistCard', () => {
|
||
test('renders own playlist without owner attribution', () => {
|
||
render(PlaylistCard, { props: { playlist: base } });
|
||
expect(screen.getByText('Saturday morning')).toBeInTheDocument();
|
||
expect(screen.getByText('12 tracks')).toBeInTheDocument();
|
||
// No "by ..." for own playlists.
|
||
expect(screen.queryByText(/by /)).not.toBeInTheDocument();
|
||
});
|
||
|
||
test('renders cover image when cover_url is set', () => {
|
||
render(PlaylistCard, { props: { playlist: { ...base, cover_url: '/api/playlists/p-1/cover' } } });
|
||
const img = screen.getByRole('img');
|
||
expect(img.getAttribute('src')).toBe('/api/playlists/p-1/cover');
|
||
});
|
||
|
||
test('renders glyph fallback when cover_url is empty', () => {
|
||
render(PlaylistCard, { props: { playlist: base } });
|
||
expect(screen.getByText(/no tracks yet/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test("attributes other users' playlists to the owner", () => {
|
||
render(PlaylistCard, { props: { playlist: { ...base, user_id: 'u-bob', owner_username: 'bob' } } });
|
||
expect(screen.getByText(/by bob/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7.3: Commit**
|
||
|
||
```bash
|
||
git add web/src/lib/components/PlaylistCard.svelte web/src/lib/components/PlaylistCard.test.ts
|
||
git commit -F - <<'EOF'
|
||
feat(web): PlaylistCard component for M7 #352 slice 1
|
||
|
||
Square card with cover (or "No tracks yet" glyph fallback), name,
|
||
track count, and owner attribution when the playlist isn't the
|
||
current user's. Click navigates to /playlists/{id}.
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8 — `<PlaylistTrackRow>` component
|
||
|
||
**Files:**
|
||
- Create: `web/src/lib/components/PlaylistTrackRow.svelte`
|
||
- Create: `web/src/lib/components/PlaylistTrackRow.test.ts`
|
||
|
||
- [ ] **Step 8.1: Write `web/src/lib/components/PlaylistTrackRow.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { GripVertical, X } from 'lucide-svelte';
|
||
import LikeButton from './LikeButton.svelte';
|
||
import TrackMenu from './TrackMenu.svelte';
|
||
import type { PlaylistTrack } from '$lib/api/types';
|
||
import type { TrackRef } from '$lib/api/types';
|
||
|
||
let {
|
||
row,
|
||
isOwner,
|
||
onRemove,
|
||
onPlay,
|
||
onDragStart,
|
||
onDragOver,
|
||
onDrop
|
||
}: {
|
||
row: PlaylistTrack;
|
||
isOwner: boolean;
|
||
onRemove: (position: number) => void;
|
||
onPlay: (position: number) => void;
|
||
onDragStart?: (position: number) => void;
|
||
onDragOver?: (e: DragEvent, position: number) => void;
|
||
onDrop?: (e: DragEvent, position: number) => void;
|
||
} = $props();
|
||
|
||
const isUnavailable = $derived(row.track_id === null);
|
||
|
||
// Construct a minimal TrackRef for the kebab menu when the upstream
|
||
// track still exists. When unavailable, the menu is hidden.
|
||
const liveTrack = $derived(
|
||
row.track_id
|
||
? {
|
||
id: row.track_id,
|
||
title: row.title,
|
||
album_id: row.album_id ?? '',
|
||
album_title: row.album_title,
|
||
artist_id: row.artist_id ?? '',
|
||
artist_name: row.artist_name,
|
||
duration_sec: row.duration_sec,
|
||
stream_url: row.stream_url ?? ''
|
||
} as TrackRef
|
||
: null
|
||
);
|
||
|
||
function format(sec: number): string {
|
||
const m = Math.floor(sec / 60);
|
||
const s = sec % 60;
|
||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||
}
|
||
</script>
|
||
|
||
<div
|
||
class="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-surface-hover
|
||
{isUnavailable ? 'text-text-muted' : 'text-text-primary'}"
|
||
draggable={isOwner && !isUnavailable}
|
||
ondragstart={() => onDragStart?.(row.position)}
|
||
ondragover={(e) => { e.preventDefault(); onDragOver?.(e, row.position); }}
|
||
ondrop={(e) => { e.preventDefault(); onDrop?.(e, row.position); }}
|
||
>
|
||
{#if isOwner}
|
||
<span
|
||
class="flex-shrink-0 cursor-grab text-text-muted active:cursor-grabbing"
|
||
aria-label="Drag handle"
|
||
>
|
||
<GripVertical size={14} strokeWidth={1} />
|
||
</span>
|
||
{/if}
|
||
|
||
<button
|
||
type="button"
|
||
class="min-w-0 flex-1 text-left"
|
||
onclick={() => !isUnavailable && onPlay(row.position)}
|
||
disabled={isUnavailable}
|
||
>
|
||
<div class="truncate {isUnavailable ? 'line-through' : ''}">{row.title}</div>
|
||
<div class="truncate text-xs text-text-muted">
|
||
{row.artist_name} · {row.album_title}
|
||
</div>
|
||
</button>
|
||
|
||
<span class="flex-shrink-0 text-xs text-text-muted">{format(row.duration_sec)}</span>
|
||
|
||
{#if !isUnavailable && liveTrack}
|
||
<LikeButton entityType="track" entityId={liveTrack.id} />
|
||
<TrackMenu track={liveTrack} />
|
||
{/if}
|
||
|
||
{#if isOwner}
|
||
<button
|
||
type="button"
|
||
onclick={() => onRemove(row.position)}
|
||
aria-label={`Remove ${row.title} from playlist`}
|
||
class="flex-shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
|
||
>
|
||
<X size={14} strokeWidth={1} />
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
```
|
||
|
||
If the project's `LikeButton` API differs (e.g., uses `kind` instead of `entityType`), adapt. Read `web/src/lib/components/LikeButton.svelte` for the canonical shape — Task 16 of M7 #356 documented this surface.
|
||
|
||
- [ ] **Step 8.2: Write `web/src/lib/components/PlaylistTrackRow.test.ts`**
|
||
|
||
```ts
|
||
import { describe, expect, test, vi } from 'vitest';
|
||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||
import PlaylistTrackRow from './PlaylistTrackRow.svelte';
|
||
import type { PlaylistTrack } from '$lib/api/types';
|
||
|
||
// LikeButton + TrackMenu transitively pull in TanStack Query and the
|
||
// auth store; mock both at module level.
|
||
vi.mock('@tanstack/svelte-query', () => ({
|
||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||
createQuery: () => ({ subscribe: () => () => {} })
|
||
}));
|
||
|
||
vi.mock('$lib/auth/store.svelte', () => ({
|
||
user: { value: { id: 'u', username: 'me', is_admin: false } }
|
||
}));
|
||
|
||
vi.mock('$lib/api/likes', () => ({
|
||
createLikedIdsQuery: () => ({
|
||
subscribe: () => () => {},
|
||
data: { track_ids: [], album_ids: [], artist_ids: [] }
|
||
}),
|
||
likeEntity: vi.fn(),
|
||
unlikeEntity: vi.fn()
|
||
}));
|
||
|
||
vi.mock('$lib/api/quarantine', () => ({
|
||
createMyQuarantineQuery: () => ({ subscribe: () => () => {} }),
|
||
flagTrack: vi.fn(),
|
||
unflagTrack: vi.fn()
|
||
}));
|
||
|
||
vi.mock('$lib/player/store.svelte', () => ({
|
||
playNext: vi.fn(),
|
||
enqueueTrack: vi.fn()
|
||
}));
|
||
|
||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||
|
||
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
|
||
|
||
const live: PlaylistTrack = {
|
||
position: 2,
|
||
track_id: 't-1',
|
||
album_id: 'a-1',
|
||
artist_id: 'ar-1',
|
||
title: 'Roygbiv',
|
||
artist_name: 'Boards of Canada',
|
||
album_title: 'MHTRTC',
|
||
duration_sec: 137,
|
||
stream_url: '/api/tracks/t-1/stream',
|
||
added_at: ''
|
||
};
|
||
|
||
const removed: PlaylistTrack = {
|
||
...live,
|
||
track_id: null,
|
||
album_id: null,
|
||
artist_id: null,
|
||
stream_url: null
|
||
};
|
||
|
||
describe('PlaylistTrackRow', () => {
|
||
test('renders title, artist, mm:ss duration', () => {
|
||
render(PlaylistTrackRow, {
|
||
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
|
||
});
|
||
expect(screen.getByText('Roygbiv')).toBeInTheDocument();
|
||
expect(screen.getByText('2:17')).toBeInTheDocument();
|
||
});
|
||
|
||
test('shows drag handle and remove button for owner', () => {
|
||
render(PlaylistTrackRow, {
|
||
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
|
||
});
|
||
expect(screen.getByLabelText('Drag handle')).toBeInTheDocument();
|
||
expect(screen.getByLabelText(/Remove Roygbiv from playlist/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test('hides drag handle and remove button for non-owner', () => {
|
||
render(PlaylistTrackRow, {
|
||
props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() }
|
||
});
|
||
expect(screen.queryByLabelText('Drag handle')).not.toBeInTheDocument();
|
||
expect(screen.queryByLabelText(/Remove/i)).not.toBeInTheDocument();
|
||
});
|
||
|
||
test('greyed-out + strikethrough when track_id is null', () => {
|
||
render(PlaylistTrackRow, {
|
||
props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
|
||
});
|
||
const titleEl = screen.getByText('Roygbiv');
|
||
expect(titleEl.className).toContain('line-through');
|
||
});
|
||
|
||
test('clicking the title fires onPlay with the position', async () => {
|
||
const onPlay = vi.fn();
|
||
render(PlaylistTrackRow, {
|
||
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay }
|
||
});
|
||
await fireEvent.click(screen.getByText('Roygbiv'));
|
||
expect(onPlay).toHaveBeenCalledWith(2);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 8.3: Commit**
|
||
|
||
```bash
|
||
git add web/src/lib/components/PlaylistTrackRow.svelte web/src/lib/components/PlaylistTrackRow.test.ts
|
||
git commit -F - <<'EOF'
|
||
feat(web): PlaylistTrackRow component for M7 #352 slice 1
|
||
|
||
Variant of TrackRow specialised for playlist detail. Drag handle
|
||
visible to owner only; remove button (X) visible to owner only;
|
||
greyed-out + strikethrough when track_id is null (upstream track
|
||
removed from library). Reuses the existing LikeButton and TrackMenu
|
||
when the track is still alive.
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9 — `<AddToPlaylistMenu>` + wire into `<TrackMenu>`
|
||
|
||
**Files:**
|
||
- Create: `web/src/lib/components/AddToPlaylistMenu.svelte`
|
||
- Create: `web/src/lib/components/AddToPlaylistMenu.test.ts`
|
||
- Modify: `web/src/lib/components/TrackMenu.svelte` — replace the disabled "Add to playlist…" entry with a real opener
|
||
- Modify: `web/src/lib/components/TrackMenu.test.ts` — update the assertion
|
||
|
||
- [ ] **Step 9.1: Write `web/src/lib/components/AddToPlaylistMenu.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { Plus } from 'lucide-svelte';
|
||
import { useQueryClient } from '@tanstack/svelte-query';
|
||
import { user } from '$lib/auth/store.svelte';
|
||
import { createPlaylistsQuery, appendTracks, createPlaylist } from '$lib/api/playlists';
|
||
import { qk } from '$lib/api/queries';
|
||
import { copyForCode } from '$lib/api/error-copy';
|
||
import TrackMenuItem from './TrackMenuItem.svelte';
|
||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||
import type { TrackRef } from '$lib/api/types';
|
||
|
||
let {
|
||
track,
|
||
onClose
|
||
}: {
|
||
track: TrackRef;
|
||
onClose: () => void;
|
||
} = $props();
|
||
|
||
const queryClient = useQueryClient();
|
||
const playlistsQ = $derived(createPlaylistsQuery());
|
||
const ownPlaylists = $derived(
|
||
($playlistsQ?.data?.owned ?? []).slice().sort((a, b) => a.name.localeCompare(b.name))
|
||
);
|
||
|
||
let creating = $state(false);
|
||
let newName = $state('');
|
||
let busy = $state(false);
|
||
let error = $state<string | null>(null);
|
||
|
||
async function add(playlistID: string) {
|
||
busy = true;
|
||
error = null;
|
||
try {
|
||
await appendTracks(playlistID, [track.id]);
|
||
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlistID) });
|
||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||
onClose();
|
||
} catch (e: unknown) {
|
||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||
error = copyForCode(code);
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
|
||
async function createAndAdd() {
|
||
if (!newName.trim()) {
|
||
error = 'Name is required';
|
||
return;
|
||
}
|
||
busy = true;
|
||
error = null;
|
||
try {
|
||
const created = await createPlaylist({ name: newName.trim() });
|
||
await appendTracks(created.id, [track.id]);
|
||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||
onClose();
|
||
} catch (e: unknown) {
|
||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||
error = copyForCode(code);
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div
|
||
role="menu"
|
||
aria-label="Add to playlist"
|
||
class="absolute right-full top-0 mr-1 w-56 rounded-md border border-border bg-surface p-1 shadow-lg"
|
||
onclick={(e) => e.stopPropagation()}
|
||
>
|
||
{#each ownPlaylists as p (p.id)}
|
||
<TrackMenuItem
|
||
icon={Plus}
|
||
label={p.name}
|
||
onclick={() => add(p.id)}
|
||
/>
|
||
{/each}
|
||
|
||
{#if ownPlaylists.length > 0}
|
||
<TrackMenuDivider />
|
||
{/if}
|
||
|
||
{#if !creating}
|
||
<TrackMenuItem
|
||
icon={Plus}
|
||
label="New playlist…"
|
||
onclick={() => { creating = true; }}
|
||
/>
|
||
{:else}
|
||
<div class="p-2">
|
||
<input
|
||
type="text"
|
||
placeholder="Playlist name"
|
||
bind:value={newName}
|
||
class="w-full rounded border border-border bg-background px-2 py-1 text-sm text-text-primary"
|
||
onkeydown={(e) => { if (e.key === 'Enter') createAndAdd(); }}
|
||
/>
|
||
<div class="mt-2 flex justify-end gap-1">
|
||
<button
|
||
type="button"
|
||
onclick={() => { creating = false; newName = ''; }}
|
||
class="rounded px-2 py-1 text-xs text-text-muted hover:bg-surface-hover"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={createAndAdd}
|
||
disabled={busy || !newName.trim()}
|
||
class="rounded bg-action-secondary px-2 py-1 text-xs text-text-primary disabled:opacity-50"
|
||
>
|
||
Create & add
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if error}
|
||
<p class="px-2 py-1 text-xs text-action-destructive">{error}</p>
|
||
{/if}
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 9.2: Write `web/src/lib/components/AddToPlaylistMenu.test.ts`**
|
||
|
||
```ts
|
||
import { describe, expect, test, vi } from 'vitest';
|
||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||
import type { TrackRef } from '$lib/api/types';
|
||
|
||
vi.mock('@tanstack/svelte-query', () => ({
|
||
useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) })
|
||
}));
|
||
|
||
vi.mock('$lib/auth/store.svelte', () => ({
|
||
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
|
||
}));
|
||
|
||
const playlistsData = {
|
||
owned: [
|
||
{ id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'B-list', description: '', is_public: false, cover_url: '', track_count: 3, duration_sec: 0, created_at: '', updated_at: '' },
|
||
{ id: 'p2', user_id: 'u-self', owner_username: 'me', name: 'A-list', description: '', is_public: false, cover_url: '', track_count: 5, duration_sec: 0, created_at: '', updated_at: '' }
|
||
],
|
||
public: []
|
||
};
|
||
|
||
vi.mock('$lib/api/playlists', () => ({
|
||
createPlaylistsQuery: () => ({
|
||
subscribe: (cb: (v: unknown) => void) => { cb({ data: playlistsData }); return () => {}; },
|
||
// The component uses `$derived(createPlaylistsQuery())` which expects
|
||
// a store-like object. Provide a `data` property directly so the
|
||
// sort-by-name fallback path can read it without a real store.
|
||
data: playlistsData
|
||
}),
|
||
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
|
||
createPlaylist: vi.fn().mockResolvedValue({ id: 'p3', name: 'New' })
|
||
}));
|
||
|
||
const track: TrackRef = {
|
||
id: 't1', title: 'Roygbiv',
|
||
album_id: 'a1', album_title: 'MHTRTC',
|
||
artist_id: 'ar1', artist_name: 'BoC',
|
||
duration_sec: 137, stream_url: '/api/tracks/t1/stream'
|
||
} as unknown as TrackRef;
|
||
|
||
describe('AddToPlaylistMenu', () => {
|
||
test('lists own playlists alphabetically', () => {
|
||
render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } });
|
||
const items = screen.getAllByRole('menuitem');
|
||
// First two should be the playlists in alpha order, then "New playlist…".
|
||
expect(items[0].textContent).toContain('A-list');
|
||
expect(items[1].textContent).toContain('B-list');
|
||
expect(items[2].textContent).toContain('New playlist');
|
||
});
|
||
|
||
test('clicking a playlist appends and closes', async () => {
|
||
const onClose = vi.fn();
|
||
const { appendTracks } = await import('$lib/api/playlists');
|
||
render(AddToPlaylistMenu, { props: { track, onClose } });
|
||
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
|
||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||
expect(appendTracks).toHaveBeenCalledWith('p2', ['t1']);
|
||
});
|
||
|
||
test('"New playlist…" toggles inline create form', async () => {
|
||
render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } });
|
||
await fireEvent.click(screen.getByRole('menuitem', { name: /New playlist/ }));
|
||
expect(screen.getByPlaceholderText(/playlist name/i)).toBeInTheDocument();
|
||
expect(screen.getByText(/Create & add/i)).toBeInTheDocument();
|
||
});
|
||
});
|
||
```
|
||
|
||
The mocked `createPlaylistsQuery` shape is approximate. If the component reads via `$derived($store?.data)`, the mock needs to produce an object with a `subscribe` method that calls back with a value containing `data`. Read `LikeButton.svelte` and its test for the project's mocking pattern of TanStack Query stores in Svelte 5.
|
||
|
||
- [ ] **Step 9.3: Wire into `TrackMenu.svelte`**
|
||
|
||
Read `web/src/lib/components/TrackMenu.svelte`. Find the disabled "Add to playlist…" entry:
|
||
|
||
```svelte
|
||
<TrackMenuItem
|
||
icon={ListMusic}
|
||
label="Add to playlist…"
|
||
disabled
|
||
title="Coming with playlists"
|
||
/>
|
||
```
|
||
|
||
Replace with an opener that mounts `<AddToPlaylistMenu>`:
|
||
|
||
```svelte
|
||
<TrackMenuItem
|
||
icon={ListMusic}
|
||
label="Add to playlist…"
|
||
onclick={() => { addOpen = true; menuOpen = false; }}
|
||
/>
|
||
```
|
||
|
||
Add `let addOpen = $state(false);` near the other state declarations. Add the popover render block alongside the existing `RemoveTrackPopover` block:
|
||
|
||
```svelte
|
||
{#if addOpen}
|
||
<AddToPlaylistMenu {track} onClose={() => (addOpen = false)} />
|
||
{/if}
|
||
```
|
||
|
||
Add the import at the top:
|
||
|
||
```svelte
|
||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||
```
|
||
|
||
- [ ] **Step 9.4: Update `TrackMenu.test.ts`**
|
||
|
||
Find the assertion that verifies "Add to playlist…" is disabled:
|
||
|
||
```ts
|
||
test('"Add to playlist…" is disabled with a tooltip until #352 ships', ...)
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```ts
|
||
test('"Add to playlist…" is enabled and opens the AddToPlaylistMenu submenu', async () => {
|
||
render(TrackMenu, { props: { track } });
|
||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||
const item = screen.getByRole('menuitem', { name: /add to playlist/i });
|
||
expect(item.getAttribute('aria-disabled')).toBe('false');
|
||
await fireEvent.click(item);
|
||
await waitFor(() =>
|
||
expect(screen.getByRole('menu', { name: /add to playlist/i })).toBeInTheDocument()
|
||
);
|
||
});
|
||
```
|
||
|
||
Add `vi.mock('$lib/api/playlists', ...)` and `vi.mock('$lib/api/queries', ...)` mocks at the top of the file if `AddToPlaylistMenu`'s imports cascade into the test. Read the existing file to see the existing mock block and extend it.
|
||
|
||
- [ ] **Step 9.5: Commit**
|
||
|
||
```bash
|
||
git add web/src/lib/components/AddToPlaylistMenu.svelte web/src/lib/components/AddToPlaylistMenu.test.ts web/src/lib/components/TrackMenu.svelte web/src/lib/components/TrackMenu.test.ts
|
||
git commit -F - <<'EOF'
|
||
feat(web): AddToPlaylistMenu submenu wired into TrackMenu (M7 #352)
|
||
|
||
The "Add to playlist…" entry that #372 reserved as a disabled slot
|
||
is now active. Submenu lists the operator's own playlists
|
||
alphabetically; "New playlist…" toggles an inline create form that
|
||
makes the playlist + appends the track in two API calls.
|
||
|
||
TrackMenu's existing test asserts the entry is enabled and opens the
|
||
submenu instead of the previous "is disabled with tooltip" check.
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10 — `/playlists` index page
|
||
|
||
**Files:**
|
||
- Modify (rewrite): `web/src/routes/playlists/+page.svelte` — replaces the placeholder
|
||
- Create: `web/src/routes/playlists/playlists.test.ts`
|
||
|
||
- [ ] **Step 10.1: Rewrite `web/src/routes/playlists/+page.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { Plus } from 'lucide-svelte';
|
||
import PlaylistCard from '$lib/components/PlaylistCard.svelte';
|
||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||
import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists';
|
||
import { useQueryClient } from '@tanstack/svelte-query';
|
||
import { qk } from '$lib/api/queries';
|
||
|
||
const playlistsQ = $derived(createPlaylistsQuery());
|
||
const queryClient = useQueryClient();
|
||
|
||
let creating = $state(false);
|
||
let newName = $state('');
|
||
let creatingBusy = $state(false);
|
||
let createError = $state<string | null>(null);
|
||
|
||
async function submitCreate() {
|
||
if (!newName.trim()) {
|
||
createError = 'Name is required';
|
||
return;
|
||
}
|
||
creatingBusy = true;
|
||
createError = null;
|
||
try {
|
||
const p = await createPlaylist({ name: newName.trim() });
|
||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||
goto(`/playlists/${p.id}`);
|
||
} catch (e: unknown) {
|
||
createError = (e as { message?: string })?.message ?? 'Could not create.';
|
||
} finally {
|
||
creatingBusy = false;
|
||
creating = false;
|
||
newName = '';
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="mx-auto max-w-6xl px-4 py-6">
|
||
<header class="mb-6 flex items-center justify-between">
|
||
<h1 class="text-2xl font-medium text-text-primary">Playlists</h1>
|
||
<button
|
||
type="button"
|
||
onclick={() => (creating = true)}
|
||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary hover:bg-action-secondary-hover"
|
||
>
|
||
<Plus size={14} strokeWidth={1} />
|
||
New playlist
|
||
</button>
|
||
</header>
|
||
|
||
{#if creating}
|
||
<div class="mb-6 rounded-md border border-border bg-surface p-4">
|
||
<label class="block text-sm font-medium text-text-primary">
|
||
Name
|
||
<input
|
||
type="text"
|
||
bind:value={newName}
|
||
placeholder="Saturday morning"
|
||
autofocus
|
||
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
|
||
onkeydown={(e) => { if (e.key === 'Enter') submitCreate(); if (e.key === 'Escape') creating = false; }}
|
||
/>
|
||
</label>
|
||
{#if createError}
|
||
<p class="mt-2 text-xs text-action-destructive">{createError}</p>
|
||
{/if}
|
||
<div class="mt-3 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||
onclick={() => { creating = false; newName = ''; createError = null; }}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={submitCreate}
|
||
disabled={creatingBusy || !newName.trim()}
|
||
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary disabled:opacity-50"
|
||
>
|
||
Create
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if $playlistsQ?.isPending}
|
||
<p class="text-text-muted">Loading playlists…</p>
|
||
{:else if $playlistsQ?.isError}
|
||
<ApiErrorBanner error={$playlistsQ.error} onRetry={() => $playlistsQ.refetch()} />
|
||
{:else if $playlistsQ?.data}
|
||
<section class="mb-8">
|
||
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-text-muted">Your playlists</h2>
|
||
{#if $playlistsQ.data.owned.length === 0}
|
||
<p class="text-sm text-text-muted">No playlists yet. Click "New playlist" to start one.</p>
|
||
{:else}
|
||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||
{#each $playlistsQ.data.owned as p (p.id)}
|
||
<PlaylistCard playlist={p} />
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
|
||
{#if $playlistsQ.data.public.length > 0}
|
||
<section>
|
||
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-text-muted">From other users</h2>
|
||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||
{#each $playlistsQ.data.public as p (p.id)}
|
||
<PlaylistCard playlist={p} />
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 10.2: Write `web/src/routes/playlists/playlists.test.ts`**
|
||
|
||
```ts
|
||
import { describe, expect, test, vi } from 'vitest';
|
||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||
import { mockQuery } from '../test-utils/query';
|
||
import type { Playlist } from '$lib/api/types';
|
||
|
||
vi.mock('$lib/api/playlists', () => ({
|
||
createPlaylistsQuery: vi.fn(),
|
||
createPlaylist: vi.fn().mockResolvedValue({ id: 'p-new', name: 'X' })
|
||
}));
|
||
|
||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||
const actual = (await orig()) as Record<string, unknown>;
|
||
return {
|
||
...actual,
|
||
useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) })
|
||
};
|
||
});
|
||
|
||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||
|
||
import PlaylistsPage from './+page.svelte';
|
||
import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists';
|
||
|
||
const mockedCreate = createPlaylistsQuery as ReturnType<typeof vi.fn>;
|
||
const mockedCreatePlaylist = createPlaylist as ReturnType<typeof vi.fn>;
|
||
|
||
function p(over: Partial<Playlist>): Playlist {
|
||
return {
|
||
id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A',
|
||
description: '', is_public: false, cover_url: '', track_count: 0, duration_sec: 0,
|
||
created_at: '', updated_at: '', ...over
|
||
};
|
||
}
|
||
|
||
describe('Playlists index page', () => {
|
||
test('renders own + public sections', () => {
|
||
mockedCreate.mockReturnValue(mockQuery({
|
||
data: {
|
||
owned: [p({ id: 'p1', name: 'Mine' })],
|
||
public: [p({ id: 'p2', name: 'Theirs', user_id: 'u-other', owner_username: 'bob' })]
|
||
}
|
||
}));
|
||
render(PlaylistsPage);
|
||
expect(screen.getByText('Mine')).toBeInTheDocument();
|
||
expect(screen.getByText('Theirs')).toBeInTheDocument();
|
||
expect(screen.getByText(/your playlists/i)).toBeInTheDocument();
|
||
expect(screen.getByText(/from other users/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test('empty-state copy when operator has no playlists', () => {
|
||
mockedCreate.mockReturnValue(mockQuery({
|
||
data: { owned: [], public: [] }
|
||
}));
|
||
render(PlaylistsPage);
|
||
expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test('Create button reveals inline form; submit creates and navigates', async () => {
|
||
mockedCreate.mockReturnValue(mockQuery({ data: { owned: [], public: [] } }));
|
||
render(PlaylistsPage);
|
||
await fireEvent.click(screen.getByRole('button', { name: /new playlist/i }));
|
||
const input = screen.getByPlaceholderText(/saturday morning/i);
|
||
await fireEvent.input(input, { target: { value: 'Test' } });
|
||
await fireEvent.click(screen.getByRole('button', { name: /^create$/i }));
|
||
await waitFor(() => expect(mockedCreatePlaylist).toHaveBeenCalledWith({ name: 'Test' }));
|
||
});
|
||
});
|
||
```
|
||
|
||
The `mockQuery` helper exists at `web/src/test-utils/query.ts` (same one used elsewhere). Read it first to confirm the import path.
|
||
|
||
- [ ] **Step 10.3: Commit**
|
||
|
||
```bash
|
||
git add web/src/routes/playlists/+page.svelte web/src/routes/playlists/playlists.test.ts
|
||
git commit -F - <<'EOF'
|
||
feat(web): /playlists index page (M7 #352 slice 1)
|
||
|
||
Replaces the placeholder route. Two sections: "Your playlists" (owned)
|
||
and "From other users" (public). Inline create form in the header
|
||
with Enter-to-submit / Esc-to-cancel. Empty-state copy when the
|
||
operator has nothing yet. Routes to /playlists/{id} on card click via
|
||
PlaylistCard.
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11 — `/playlists/{id}` detail page with drag-reorder
|
||
|
||
**Files:**
|
||
- Create: `web/src/routes/playlists/[id]/+page.svelte`
|
||
- Create: `web/src/routes/playlists/[id]/playlist.test.ts`
|
||
|
||
- [ ] **Step 11.1: Write `web/src/routes/playlists/[id]/+page.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { page } from '$app/stores';
|
||
import { goto } from '$app/navigation';
|
||
import { Pencil, Trash2 } from 'lucide-svelte';
|
||
import { useQueryClient } from '@tanstack/svelte-query';
|
||
import PlaylistTrackRow from '$lib/components/PlaylistTrackRow.svelte';
|
||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||
import {
|
||
createPlaylistQuery,
|
||
updatePlaylist,
|
||
deletePlaylist,
|
||
removePlaylistTrack,
|
||
reorderPlaylist
|
||
} from '$lib/api/playlists';
|
||
import { qk } from '$lib/api/queries';
|
||
import { user } from '$lib/auth/store.svelte';
|
||
import { copyForCode } from '$lib/api/error-copy';
|
||
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
||
import type { TrackRef } from '$lib/api/types';
|
||
|
||
const id = $derived($page.params.id);
|
||
const queryClient = useQueryClient();
|
||
const playlistQ = $derived(createPlaylistQuery(id));
|
||
const isOwner = $derived(user.value?.id === $playlistQ?.data?.user_id);
|
||
|
||
// Local optimistic ordering for drag-reorder. Falls back to the
|
||
// server's order when no drag is in progress.
|
||
let dragFromPos = $state<number | null>(null);
|
||
|
||
async function onDrop(toPos: number) {
|
||
if (dragFromPos === null || !$playlistQ?.data) return;
|
||
if (dragFromPos === toPos) {
|
||
dragFromPos = null;
|
||
return;
|
||
}
|
||
const cur = $playlistQ.data.tracks.map((t) => t.position);
|
||
const moved = cur.splice(dragFromPos, 1)[0];
|
||
cur.splice(toPos, 0, moved);
|
||
dragFromPos = null;
|
||
try {
|
||
await reorderPlaylist(id, cur);
|
||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||
} catch (e: unknown) {
|
||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||
alert(copyForCode(code)); // simple fallback; toast surface is a polish item
|
||
}
|
||
}
|
||
|
||
async function onRemove(position: number) {
|
||
try {
|
||
await removePlaylistTrack(id, position);
|
||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||
} catch (e: unknown) {
|
||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||
alert(copyForCode(code));
|
||
}
|
||
}
|
||
|
||
function onPlay(position: number) {
|
||
if (!$playlistQ?.data) return;
|
||
const live = $playlistQ.data.tracks
|
||
.filter((t) => t.track_id !== null)
|
||
.map(toTrackRef);
|
||
const startIdx = live.findIndex((_, i) => $playlistQ.data!.tracks[i].position === position);
|
||
playQueue(live, Math.max(0, startIdx));
|
||
}
|
||
|
||
function toTrackRef(t: { track_id: string | null; album_id: string | null; artist_id: string | null; title: string; album_title: string; artist_name: string; duration_sec: number; stream_url: string | null }): TrackRef {
|
||
return {
|
||
id: t.track_id!,
|
||
title: t.title,
|
||
album_id: t.album_id ?? '',
|
||
album_title: t.album_title,
|
||
artist_id: t.artist_id ?? '',
|
||
artist_name: t.artist_name,
|
||
duration_sec: t.duration_sec,
|
||
stream_url: t.stream_url ?? ''
|
||
} as unknown as TrackRef;
|
||
}
|
||
|
||
let editing = $state(false);
|
||
let editName = $state('');
|
||
let editDesc = $state('');
|
||
let editIsPublic = $state(false);
|
||
|
||
function startEdit() {
|
||
if (!$playlistQ?.data) return;
|
||
editName = $playlistQ.data.name;
|
||
editDesc = $playlistQ.data.description;
|
||
editIsPublic = $playlistQ.data.is_public;
|
||
editing = true;
|
||
}
|
||
|
||
async function submitEdit() {
|
||
try {
|
||
await updatePlaylist(id, { name: editName, description: editDesc, is_public: editIsPublic });
|
||
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
|
||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||
editing = false;
|
||
} catch (e: unknown) {
|
||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||
alert(copyForCode(code));
|
||
}
|
||
}
|
||
|
||
async function submitDelete() {
|
||
if (!confirm('Delete this playlist? This cannot be undone.')) return;
|
||
try {
|
||
await deletePlaylist(id);
|
||
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
|
||
goto('/playlists');
|
||
} catch (e: unknown) {
|
||
const code = (e as { code?: string })?.code ?? 'unknown';
|
||
alert(copyForCode(code));
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="mx-auto max-w-4xl px-4 py-6">
|
||
{#if $playlistQ?.isPending}
|
||
<p class="text-text-muted">Loading…</p>
|
||
{:else if $playlistQ?.isError}
|
||
<ApiErrorBanner error={$playlistQ.error} onRetry={() => $playlistQ.refetch()} />
|
||
{:else if $playlistQ?.data}
|
||
{@const pl = $playlistQ.data}
|
||
{#if !editing}
|
||
<header class="mb-6 flex items-start gap-4">
|
||
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-secondary">
|
||
{#if pl.cover_url}
|
||
<img src={pl.cover_url} alt="" class="h-full w-full object-cover" />
|
||
{/if}
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<h1 class="truncate text-2xl font-medium text-text-primary">{pl.name}</h1>
|
||
{#if pl.description}
|
||
<p class="mt-1 text-sm text-text-muted">{pl.description}</p>
|
||
{/if}
|
||
<p class="mt-2 text-xs text-text-muted">
|
||
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'}
|
||
{#if pl.is_public}· public{:else}· private{/if}
|
||
{#if !isOwner}· by {pl.owner_username}{/if}
|
||
</p>
|
||
</div>
|
||
{#if isOwner}
|
||
<button
|
||
type="button"
|
||
onclick={startEdit}
|
||
aria-label="Edit playlist"
|
||
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-text-primary"
|
||
>
|
||
<Pencil size={16} strokeWidth={1} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={submitDelete}
|
||
aria-label="Delete playlist"
|
||
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-action-destructive"
|
||
>
|
||
<Trash2 size={16} strokeWidth={1} />
|
||
</button>
|
||
{/if}
|
||
</header>
|
||
{:else}
|
||
<section class="mb-6 rounded-md border border-border bg-surface p-4">
|
||
<label class="block text-sm font-medium text-text-primary">
|
||
Name
|
||
<input
|
||
type="text"
|
||
bind:value={editName}
|
||
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
|
||
/>
|
||
</label>
|
||
<label class="mt-3 block text-sm font-medium text-text-primary">
|
||
Description
|
||
<textarea
|
||
bind:value={editDesc}
|
||
rows="2"
|
||
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
|
||
></textarea>
|
||
</label>
|
||
<label class="mt-3 flex items-center gap-2 text-sm text-text-primary">
|
||
<input type="checkbox" bind:checked={editIsPublic} class="rounded border-border" />
|
||
Make this playlist public
|
||
</label>
|
||
<div class="mt-3 flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onclick={() => (editing = false)}
|
||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={submitEdit}
|
||
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
|
||
>
|
||
Save
|
||
</button>
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
|
||
<section class="rounded-md border border-border bg-surface">
|
||
{#if pl.tracks.length === 0}
|
||
<p class="px-3 py-6 text-sm text-text-muted">
|
||
{#if isOwner}
|
||
No tracks yet. Add some via the "Add to playlist…" entry on any track row.
|
||
{:else}
|
||
No tracks in this playlist.
|
||
{/if}
|
||
</p>
|
||
{:else}
|
||
{#each pl.tracks as row (row.position)}
|
||
<PlaylistTrackRow
|
||
{row}
|
||
isOwner={isOwner ?? false}
|
||
onRemove={onRemove}
|
||
onPlay={onPlay}
|
||
onDragStart={(p) => (dragFromPos = p)}
|
||
onDrop={(_, p) => onDrop(p)}
|
||
/>
|
||
{/each}
|
||
{/if}
|
||
</section>
|
||
{/if}
|
||
</div>
|
||
```
|
||
|
||
`enqueueTracks` and `playQueue` are existing player-store exports per Task 5 of M7 #356. Read `web/src/lib/player/store.svelte.ts` for the exact signatures and adapt — the snippet uses `playQueue(tracks, startIndex)`. If the actual signature differs, adjust.
|
||
|
||
The `alert(...)` toast fallback is intentional: a real toast surface is a separate polish task; in slice 1, a browser alert is loud-but-functional and won't be missed in dev. CI tests won't exercise the alert path because they intercept via mocked fetch.
|
||
|
||
- [ ] **Step 11.2: Write `web/src/routes/playlists/[id]/playlist.test.ts`**
|
||
|
||
```ts
|
||
import { describe, expect, test, vi } from 'vitest';
|
||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||
import { mockQuery } from '../../test-utils/query';
|
||
import { writable } from 'svelte/store';
|
||
import type { PlaylistDetail } from '$lib/api/types';
|
||
|
||
vi.mock('$app/stores', () => ({
|
||
page: writable({ params: { id: 'p1' } })
|
||
}));
|
||
|
||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||
|
||
vi.mock('$lib/api/playlists', () => ({
|
||
createPlaylistQuery: vi.fn(),
|
||
updatePlaylist: vi.fn().mockResolvedValue(undefined),
|
||
deletePlaylist: vi.fn().mockResolvedValue(undefined),
|
||
removePlaylistTrack: vi.fn().mockResolvedValue(undefined),
|
||
reorderPlaylist: vi.fn().mockResolvedValue(undefined)
|
||
}));
|
||
|
||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||
const actual = (await orig()) as Record<string, unknown>;
|
||
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) };
|
||
});
|
||
|
||
vi.mock('$lib/auth/store.svelte', () => ({
|
||
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
|
||
}));
|
||
|
||
vi.mock('$lib/player/store.svelte', () => ({
|
||
playQueue: vi.fn(),
|
||
enqueueTracks: vi.fn(),
|
||
playNext: vi.fn(),
|
||
enqueueTrack: vi.fn()
|
||
}));
|
||
|
||
// Cascade through PlaylistTrackRow's mocks.
|
||
vi.mock('$lib/api/likes', () => ({
|
||
createLikedIdsQuery: () => ({ subscribe: () => () => {}, data: { track_ids: [], album_ids: [], artist_ids: [] } }),
|
||
likeEntity: vi.fn(),
|
||
unlikeEntity: vi.fn()
|
||
}));
|
||
vi.mock('$lib/api/quarantine', () => ({
|
||
createMyQuarantineQuery: () => ({ subscribe: () => () => {}, data: [] }),
|
||
flagTrack: vi.fn(),
|
||
unflagTrack: vi.fn()
|
||
}));
|
||
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
|
||
|
||
import PlaylistDetailPage from './+page.svelte';
|
||
import { createPlaylistQuery, deletePlaylist, reorderPlaylist } from '$lib/api/playlists';
|
||
|
||
const mockedQuery = createPlaylistQuery as ReturnType<typeof vi.fn>;
|
||
|
||
const ownDetail: PlaylistDetail = {
|
||
id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'Mine',
|
||
description: '', is_public: false, cover_url: '', track_count: 2, duration_sec: 274,
|
||
created_at: '', updated_at: '',
|
||
tracks: [
|
||
{ position: 0, track_id: 't1', album_id: 'a1', artist_id: 'ar1', title: 'A', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t1/stream', added_at: '' },
|
||
{ position: 1, track_id: 't2', album_id: 'a1', artist_id: 'ar1', title: 'B', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t2/stream', added_at: '' }
|
||
]
|
||
};
|
||
|
||
describe('Playlist detail page', () => {
|
||
test('renders header + tracks for owner', () => {
|
||
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
|
||
render(PlaylistDetailPage);
|
||
expect(screen.getByText('Mine')).toBeInTheDocument();
|
||
expect(screen.getByText('A')).toBeInTheDocument();
|
||
expect(screen.getByText('B')).toBeInTheDocument();
|
||
expect(screen.getByLabelText(/edit playlist/i)).toBeInTheDocument();
|
||
expect(screen.getByLabelText(/delete playlist/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test('hides edit + delete for non-owner', () => {
|
||
mockedQuery.mockReturnValue(mockQuery({
|
||
data: { ...ownDetail, user_id: 'u-other', owner_username: 'bob', is_public: true }
|
||
}));
|
||
render(PlaylistDetailPage);
|
||
expect(screen.queryByLabelText(/edit playlist/i)).not.toBeInTheDocument();
|
||
expect(screen.queryByLabelText(/delete playlist/i)).not.toBeInTheDocument();
|
||
});
|
||
|
||
test('Delete button confirms then deletes', async () => {
|
||
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
|
||
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
|
||
render(PlaylistDetailPage);
|
||
await fireEvent.click(screen.getByLabelText(/delete playlist/i));
|
||
await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1'));
|
||
confirmSpy.mockRestore();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 11.3: Commit**
|
||
|
||
```bash
|
||
git add web/src/routes/playlists/[id]/+page.svelte web/src/routes/playlists/[id]/playlist.test.ts
|
||
git commit -F - <<'EOF'
|
||
feat(web): /playlists/{id} detail page with drag-reorder (M7 #352)
|
||
|
||
Header shows collage, name, description, public/private chip, track
|
||
count, owner attribution (when not owner). Edit + delete buttons
|
||
visible to the owner only. Track list uses PlaylistTrackRow with
|
||
HTML5 drag-and-drop; drop fires PUT /tracks with the new ordered
|
||
positions and TanStack Query invalidates the playlist + index cache.
|
||
|
||
Toast surface is a placeholder browser alert in slice 1 — a real
|
||
toast is a polish task whenever it lands.
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review
|
||
|
||
1. **Spec coverage:**
|
||
- §2 Goals — all goals point at tasks: schema (T1), service (T2 + T3), collage (T4), API (T5), api helper + types + queries (T6), PlaylistCard (T7), PlaylistTrackRow (T8), AddToPlaylistMenu + TrackMenu wiring (T9), `/playlists` (T10), `/playlists/{id}` (T11). ✓
|
||
- §3 Architecture — schema, service, API, frontend pages all covered.
|
||
- §3.5 Drag-to-reorder — HTML5 native, optimistic update, T11 wires it. ✓
|
||
- §3.6 "Add to playlist" flow — T9 covers submenu + inline new-playlist creation. ✓
|
||
- §3.7 Permissions matrix — service-layer enforces ErrForbidden in T2/T3, API layer maps to 403 not_authorized in T5. ✓
|
||
- §5 API contract — every endpoint has a handler in T5 with matching response shape. ✓
|
||
- §6 Error handling — `copyForCode` invocations land in AddToPlaylistMenu (T9) and the detail page (T11). The `alert()` fallback is documented as a slice-1 trade-off.
|
||
- §7 Testing — every test file from §7 has a writing step.
|
||
- §8 Distribution — migration 0014 lives in T1; data dir creation is service-side in T4 (`os.MkdirAll`).
|
||
|
||
2. **Placeholder scan:** No "TBD"/"add validation"/"handle edge cases" in any task. The alert() toast is documented intentional. The fallback glyph is a documented intentional slice-1 placeholder.
|
||
|
||
3. **Type consistency:**
|
||
- Backend service signatures: `Service.AppendTracks(ctx, callerID, playlistID, []pgtype.UUID) error` and friends — consistent across T2 / T3 / T5.
|
||
- `PlaylistRow`, `PlaylistDetail`, `PlaylistTrack` Go types match between service.go and the API view structs in T5.
|
||
- TS types: `Playlist`, `PlaylistDetail`, `PlaylistTrack` — defined in T6, used everywhere downstream.
|
||
- Query keys: `qk.playlists()`, `qk.playlist(id)` — defined in T6, used in T9, T10, T11.
|
||
- API URLs: every helper in T6 matches a handler route registered in T5.
|
||
|
||
One spec-deviation worth noting:
|
||
|
||
- The spec's §3.2 says "Triggers cover regeneration if the first 4 positions changed" for Reorder. The plan's T3 implementation triggers regeneration unconditionally on any reorder. Reasoning baked in: "cheap, and reasoning about which moves matter is more error-prone than just doing the work." Acceptable — net no behavioral difference for slice 1 (collages get regenerated; might just happen on a few extra calls).
|