a3c05aeb34
9 tasks: schema + sqlc, native API, Subsonic star/unstar, Subsonic getStarred/getStarred2, web likes.ts factory + mutations, LikeButton component, wire LikeButton into existing components, /library/liked page, final verification.
2496 lines
82 KiB
Markdown
2496 lines
82 KiB
Markdown
# M2 Likes Sub-Plan Implementation
|
||
|
||
> **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:** Ship liking end-to-end across all three entity types (track / album / artist), wired through both the native `/api/*` surface and the Subsonic `/rest/*` compatibility surface, with web UI heart buttons everywhere a track/album/artist is rendered, plus a dedicated `/library/liked` page. Closes M2.
|
||
|
||
**Architecture:** Three new tables (one per entity type) keyed on `(user_id, entity_id)` with `liked_at`. Native `/api/likes/{tracks,albums,artists}/:id` endpoint families (POST/DELETE/GET) plus `/api/likes/ids` for client-side heart caching. Subsonic `/rest/star`, `/rest/unstar`, `/rest/getStarred`, `/rest/getStarred2` route through the same per-table writes with validate-all-first atomicity. Web client uses one `LikeButton.svelte` reading a single `createLikedIdsQuery()` cache; mutations do optimistic updates with rollback.
|
||
|
||
**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 (runes) + TypeScript + TanStack Query (Svelte adapter) + Vitest + Testing Library.
|
||
|
||
**Reference:** design spec at `docs/superpowers/specs/2026-04-26-m2-likes-design.md`.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**New server files:**
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `internal/db/migrations/0006_likes.up.sql` + `.down.sql` | Schema for `general_likes`, `general_likes_albums`, `general_likes_artists`. |
|
||
| `internal/db/queries/likes.sql` | sqlc queries (like/unlike/list/count/list-ids per table). Generated bindings emitted into `internal/db/dbq/likes.sql.go`. |
|
||
| `internal/api/likes.go` | Native `/api/likes/...` handlers (3 like, 3 unlike, 3 list, 1 ids). |
|
||
| `internal/api/likes_test.go` | HTTP-level coverage. |
|
||
| `internal/subsonic/star.go` | `handleStar`, `handleUnstar`, `handleGetStarred`, `handleGetStarred2`. |
|
||
| `internal/subsonic/star_test.go` | Subsonic coverage. |
|
||
|
||
**Modified server files:**
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| `internal/api/api.go` | Register the seven new `/api/likes/...` routes inside the authed group. |
|
||
| `internal/subsonic/subsonic.go` | Register `/star`, `/unstar`, `/getStarred`, `/getStarred2`. |
|
||
| `internal/subsonic/types.go` | Add `StarredContainer` / `Starred2Container` types. |
|
||
|
||
**New web files:**
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `web/src/lib/api/likes.ts` | TanStack Query helpers + mutations. Single source for liked-id state. |
|
||
| `web/src/lib/api/likes.test.ts` | Mutation/cache-update unit tests. |
|
||
| `web/src/lib/components/LikeButton.svelte` | Heart icon button. Props `entityType: 'track' \| 'album' \| 'artist'`, `entityId: string`. |
|
||
| `web/src/lib/components/LikeButton.test.ts` | Component tests. |
|
||
| `web/src/routes/library/liked/+page.svelte` | Three sections (Artists/Albums/Tracks) with infinite queries. |
|
||
| `web/src/routes/library/liked/liked.test.ts` | Page integration tests. |
|
||
|
||
**Modified web files:**
|
||
|
||
| File | Change |
|
||
|---|---|
|
||
| `web/src/lib/api/types.ts` | Add `LikedIdsResponse`. |
|
||
| `web/src/lib/api/queries.ts` | Add `qk.likedIds`, `qk.likedTracks`, `qk.likedAlbums`, `qk.likedArtists`. |
|
||
| `web/src/lib/components/TrackRow.svelte` | Insert `<LikeButton entityType="track" entityId={track.id} />` next to `+ queue`. Grid grows to `[32px_1fr_auto_auto_auto]`. |
|
||
| `web/src/lib/components/AlbumCard.svelte` | Add `<LikeButton entityType="album" entityId={album.id} />` overlay. |
|
||
| `web/src/lib/components/ArtistRow.svelte` | Add `<LikeButton entityType="artist" entityId={artist.id} />` before the chevron. |
|
||
| `web/src/lib/components/PlayerBar.svelte` | Add `<LikeButton entityType="track" entityId={current.id} />` next to title. |
|
||
| `web/src/lib/components/Shell.svelte` | Add "Liked" entry to `navItems` pointing at `/library/liked`. |
|
||
|
||
---
|
||
|
||
## Task 1: Schema migration + sqlc queries
|
||
|
||
**Files:**
|
||
- Create: `internal/db/migrations/0006_likes.up.sql`
|
||
- Create: `internal/db/migrations/0006_likes.down.sql`
|
||
- Create: `internal/db/queries/likes.sql`
|
||
- Generated: `internal/db/dbq/likes.sql.go` (via `sqlc generate`)
|
||
|
||
- [ ] **Step 1: Write the up migration**
|
||
|
||
Create `internal/db/migrations/0006_likes.up.sql`:
|
||
|
||
```sql
|
||
-- M2 likes: three tables, one per entity type. Schema follows the spec §5
|
||
-- pattern (general_likes was originally track-only); this slice extends it
|
||
-- to albums and artists for full Subsonic star/unstar support.
|
||
|
||
CREATE TABLE general_likes (
|
||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||
liked_at timestamptz NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (user_id, track_id)
|
||
);
|
||
CREATE INDEX general_likes_user_liked_at_idx ON general_likes (user_id, liked_at DESC);
|
||
|
||
CREATE TABLE general_likes_albums (
|
||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||
liked_at timestamptz NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (user_id, album_id)
|
||
);
|
||
CREATE INDEX general_likes_albums_user_liked_at_idx ON general_likes_albums (user_id, liked_at DESC);
|
||
|
||
CREATE TABLE general_likes_artists (
|
||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
||
liked_at timestamptz NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (user_id, artist_id)
|
||
);
|
||
CREATE INDEX general_likes_artists_user_liked_at_idx ON general_likes_artists (user_id, liked_at DESC);
|
||
```
|
||
|
||
- [ ] **Step 2: Write the down migration**
|
||
|
||
Create `internal/db/migrations/0006_likes.down.sql`:
|
||
|
||
```sql
|
||
DROP TABLE IF EXISTS general_likes_artists;
|
||
DROP TABLE IF EXISTS general_likes_albums;
|
||
DROP TABLE IF EXISTS general_likes;
|
||
```
|
||
|
||
- [ ] **Step 3: Write the sqlc queries**
|
||
|
||
Create `internal/db/queries/likes.sql`:
|
||
|
||
```sql
|
||
-- name: LikeTrack :exec
|
||
INSERT INTO general_likes (user_id, track_id)
|
||
VALUES ($1, $2)
|
||
ON CONFLICT (user_id, track_id) DO NOTHING;
|
||
|
||
-- name: UnlikeTrack :exec
|
||
DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2;
|
||
|
||
-- name: ListLikedTrackRows :many
|
||
SELECT t.* FROM tracks t
|
||
JOIN general_likes l ON l.track_id = t.id
|
||
WHERE l.user_id = $1
|
||
ORDER BY l.liked_at DESC
|
||
LIMIT $2 OFFSET $3;
|
||
|
||
-- name: CountLikedTracks :one
|
||
SELECT count(*) FROM general_likes WHERE user_id = $1;
|
||
|
||
-- name: ListLikedTrackIDs :many
|
||
SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC;
|
||
|
||
-- name: LikeAlbum :exec
|
||
INSERT INTO general_likes_albums (user_id, album_id)
|
||
VALUES ($1, $2)
|
||
ON CONFLICT (user_id, album_id) DO NOTHING;
|
||
|
||
-- name: UnlikeAlbum :exec
|
||
DELETE FROM general_likes_albums WHERE user_id = $1 AND album_id = $2;
|
||
|
||
-- name: ListLikedAlbumRows :many
|
||
SELECT a.* FROM albums a
|
||
JOIN general_likes_albums l ON l.album_id = a.id
|
||
WHERE l.user_id = $1
|
||
ORDER BY l.liked_at DESC
|
||
LIMIT $2 OFFSET $3;
|
||
|
||
-- name: CountLikedAlbums :one
|
||
SELECT count(*) FROM general_likes_albums WHERE user_id = $1;
|
||
|
||
-- name: ListLikedAlbumIDs :many
|
||
SELECT album_id FROM general_likes_albums WHERE user_id = $1 ORDER BY liked_at DESC;
|
||
|
||
-- name: LikeArtist :exec
|
||
INSERT INTO general_likes_artists (user_id, artist_id)
|
||
VALUES ($1, $2)
|
||
ON CONFLICT (user_id, artist_id) DO NOTHING;
|
||
|
||
-- name: UnlikeArtist :exec
|
||
DELETE FROM general_likes_artists WHERE user_id = $1 AND artist_id = $2;
|
||
|
||
-- name: ListLikedArtistRows :many
|
||
SELECT a.* FROM artists a
|
||
JOIN general_likes_artists l ON l.artist_id = a.id
|
||
WHERE l.user_id = $1
|
||
ORDER BY l.liked_at DESC
|
||
LIMIT $2 OFFSET $3;
|
||
|
||
-- name: CountLikedArtists :one
|
||
SELECT count(*) FROM general_likes_artists WHERE user_id = $1;
|
||
|
||
-- name: ListLikedArtistIDs :many
|
||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1 ORDER BY liked_at DESC;
|
||
```
|
||
|
||
- [ ] **Step 4: Generate sqlc bindings**
|
||
|
||
Run: `$(go env GOPATH)/bin/sqlc generate`
|
||
Expected: writes `internal/db/dbq/likes.sql.go` with the new query functions; no other dbq files change beyond the version comment.
|
||
|
||
- [ ] **Step 5: Verify build**
|
||
|
||
Run: `go build ./...`
|
||
Expected: clean build.
|
||
|
||
- [ ] **Step 6: Migration smoke test**
|
||
|
||
Run:
|
||
```bash
|
||
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
||
go test ./internal/db -run TestMigrate -v
|
||
```
|
||
Expected: PASS — exercises 0006 up/down via the existing migration test.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add internal/db/migrations/0006_likes.up.sql \
|
||
internal/db/migrations/0006_likes.down.sql \
|
||
internal/db/queries/likes.sql \
|
||
internal/db/dbq/likes.sql.go \
|
||
internal/db/dbq/models.go
|
||
git commit -m "feat(db): add M2 likes schema (general_likes, _albums, _artists)
|
||
|
||
Three tables keyed on (user_id, entity_id) with liked_at. Per-table
|
||
indexes on (user_id, liked_at DESC) for the recently-liked feed.
|
||
sqlc queries cover like/unlike/list-rows/count/list-ids per entity."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Native `/api/likes/...` handlers
|
||
|
||
**Files:**
|
||
- Create: `internal/api/likes.go`
|
||
- Create: `internal/api/likes_test.go`
|
||
- Modify: `internal/api/api.go`
|
||
|
||
Single handler file owning all seven routes (3 like, 3 unlike, 3 list, 1 ids). Tests follow the existing `events_test.go` pattern with `userCtxKeyForTest()` injection.
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `internal/api/likes_test.go`:
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
func callLike(h *handlers, user dbq.User, method, path string) *httptest.ResponseRecorder {
|
||
req := httptest.NewRequest(method, path, nil)
|
||
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
|
||
w := httptest.NewRecorder()
|
||
likesRouter(h).ServeHTTP(w, req)
|
||
return w
|
||
}
|
||
|
||
// likesRouter matches the routes registered in Mount but without RequireUser
|
||
// so tests can inject the user via context directly.
|
||
func likesRouter(h *handlers) http.Handler {
|
||
r := chi.NewRouter()
|
||
r.Post("/api/likes/tracks/{id}", h.handleLikeTrack)
|
||
r.Delete("/api/likes/tracks/{id}", h.handleUnlikeTrack)
|
||
r.Post("/api/likes/albums/{id}", h.handleLikeAlbum)
|
||
r.Delete("/api/likes/albums/{id}", h.handleUnlikeAlbum)
|
||
r.Post("/api/likes/artists/{id}", h.handleLikeArtist)
|
||
r.Delete("/api/likes/artists/{id}", h.handleUnlikeArtist)
|
||
r.Get("/api/likes/tracks", h.handleListLikedTracks)
|
||
r.Get("/api/likes/albums", h.handleListLikedAlbums)
|
||
r.Get("/api/likes/artists", h.handleListLikedArtists)
|
||
r.Get("/api/likes/ids", h.handleGetLikedIDs)
|
||
return r
|
||
}
|
||
|
||
func TestLikeTrack_Idempotent(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
user := seedUser(t, pool, "alice", "x", false)
|
||
artist := seedArtist(t, pool, "Beatles")
|
||
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
|
||
track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
|
||
|
||
w := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
|
||
if w.Code != http.StatusNoContent {
|
||
t.Fatalf("first like: status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
w = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
|
||
if w.Code != http.StatusNoContent {
|
||
t.Errorf("second like (idempotent): status = %d", w.Code)
|
||
}
|
||
var count int
|
||
_ = pool.QueryRow(context.Background(),
|
||
"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
|
||
if count != 1 {
|
||
t.Errorf("rows = %d, want 1", count)
|
||
}
|
||
}
|
||
|
||
func TestLikeTrack_BadUUIDIs400(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
user := seedUser(t, pool, "alice", "x", false)
|
||
w := callLike(h, user, http.MethodPost, "/api/likes/tracks/not-a-uuid")
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestLikeTrack_UnknownTrackIs404(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
user := seedUser(t, pool, "alice", "x", false)
|
||
w := callLike(h, user, http.MethodPost, "/api/likes/tracks/00000000-0000-0000-0000-000000000000")
|
||
if w.Code != http.StatusNotFound {
|
||
t.Errorf("status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestUnlikeTrack_IdempotentEvenWhenAbsent(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
user := seedUser(t, pool, "alice", "x", false)
|
||
artist := seedArtist(t, pool, "Beatles")
|
||
album := seedAlbum(t, pool, artist.ID, "Abbey Road", 1969)
|
||
track := seedTrack(t, pool, album.ID, artist.ID, "Something", 3, 183_000)
|
||
|
||
w := callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(track.ID))
|
||
if w.Code != http.StatusNoContent {
|
||
t.Errorf("status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestListLikedTracks_PaginatedAndSortedDescByLikedAt(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
user := seedUser(t, pool, "alice", "x", false)
|
||
artist := seedArtist(t, pool, "X")
|
||
album := seedAlbum(t, pool, artist.ID, "X", 1990)
|
||
t1 := seedTrack(t, pool, album.ID, artist.ID, "First", 1, 100_000)
|
||
t2 := seedTrack(t, pool, album.ID, artist.ID, "Second", 2, 100_000)
|
||
|
||
// Like t1 then t2 — newest should come first.
|
||
_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(t1.ID))
|
||
_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(t2.ID))
|
||
|
||
w := callLike(h, user, http.MethodGet, "/api/likes/tracks?limit=10&offset=0")
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
var resp struct {
|
||
Items []TrackRef `json:"items"`
|
||
Total int `json:"total"`
|
||
Limit int `json:"limit"`
|
||
Offset int `json:"offset"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||
if resp.Total != 2 || len(resp.Items) != 2 {
|
||
t.Fatalf("shape: %+v", resp)
|
||
}
|
||
if resp.Items[0].Title != "Second" {
|
||
t.Errorf("first item = %q, want %q (most recent)", resp.Items[0].Title, "Second")
|
||
}
|
||
}
|
||
|
||
func TestGetLikedIDs_ReturnsAllThreeArrays(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
user := seedUser(t, pool, "alice", "x", false)
|
||
artist := seedArtist(t, pool, "X")
|
||
album := seedAlbum(t, pool, artist.ID, "X", 1990)
|
||
track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000)
|
||
|
||
_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
|
||
_ = callLike(h, user, http.MethodPost, "/api/likes/albums/"+uuidToString(album.ID))
|
||
_ = callLike(h, user, http.MethodPost, "/api/likes/artists/"+uuidToString(artist.ID))
|
||
|
||
w := callLike(h, user, http.MethodGet, "/api/likes/ids")
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", w.Code)
|
||
}
|
||
var resp struct {
|
||
TrackIDs []string `json:"track_ids"`
|
||
AlbumIDs []string `json:"album_ids"`
|
||
ArtistIDs []string `json:"artist_ids"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||
if len(resp.TrackIDs) != 1 || len(resp.AlbumIDs) != 1 || len(resp.ArtistIDs) != 1 {
|
||
t.Errorf("ids = %+v", resp)
|
||
}
|
||
}
|
||
|
||
func TestGetLikedIDs_CrossUserIsolation(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
alice := seedUser(t, pool, "alice", "x", false)
|
||
bob := seedUser(t, pool, "bob", "x", false)
|
||
artist := seedArtist(t, pool, "X")
|
||
album := seedAlbum(t, pool, artist.ID, "X", 1990)
|
||
track := seedTrack(t, pool, album.ID, artist.ID, "T", 1, 100_000)
|
||
|
||
// Alice likes the track.
|
||
_ = callLike(h, alice, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID))
|
||
// Bob queries his ids — should be empty.
|
||
w := callLike(h, bob, http.MethodGet, "/api/likes/ids")
|
||
var resp struct {
|
||
TrackIDs []string `json:"track_ids"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||
if len(resp.TrackIDs) != 0 {
|
||
t.Errorf("bob's track_ids should be empty, got %v", resp.TrackIDs)
|
||
}
|
||
}
|
||
|
||
func TestLikeAlbumAndArtist_HappyPath(t *testing.T) {
|
||
h, pool := testHandlers(t)
|
||
truncateLibrary(t, pool)
|
||
user := seedUser(t, pool, "alice", "x", false)
|
||
artist := seedArtist(t, pool, "X")
|
||
album := seedAlbum(t, pool, artist.ID, "X", 1990)
|
||
|
||
w := callLike(h, user, http.MethodPost, "/api/likes/albums/"+uuidToString(album.ID))
|
||
if w.Code != http.StatusNoContent {
|
||
t.Errorf("album: status = %d", w.Code)
|
||
}
|
||
w = callLike(h, user, http.MethodPost, "/api/likes/artists/"+uuidToString(artist.ID))
|
||
if w.Code != http.StatusNoContent {
|
||
t.Errorf("artist: status = %d", w.Code)
|
||
}
|
||
w = callLike(h, user, http.MethodGet, "/api/likes/albums?limit=10")
|
||
var resp struct{ Total int `json:"total"` }
|
||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||
if resp.Total != 1 {
|
||
t.Errorf("album total = %d", resp.Total)
|
||
}
|
||
}
|
||
```
|
||
|
||
The test imports `chi`; add that import alongside the existing ones.
|
||
|
||
- [ ] **Step 2: Run tests, confirm they fail**
|
||
|
||
Run:
|
||
```bash
|
||
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
||
go test ./internal/api -run TestLike -v
|
||
```
|
||
Expected: FAIL — handlers undefined.
|
||
|
||
- [ ] **Step 3: Implement `internal/api/likes.go`**
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
type likedIDsResponse struct {
|
||
TrackIDs []string `json:"track_ids"`
|
||
AlbumIDs []string `json:"album_ids"`
|
||
ArtistIDs []string `json:"artist_ids"`
|
||
}
|
||
|
||
func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
|
||
return
|
||
}
|
||
q := dbq.New(h.pool)
|
||
if _, err := q.GetTrackByID(r.Context(), id); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
writeErr(w, http.StatusNotFound, "not_found", "track not found")
|
||
return
|
||
}
|
||
h.logger.Error("api: like track lookup", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
|
||
h.logger.Error("api: like track insert", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
|
||
return
|
||
}
|
||
if err := dbq.New(h.pool).UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
|
||
h.logger.Error("api: unlike track", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "delete failed")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (h *handlers) handleLikeAlbum(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
|
||
return
|
||
}
|
||
q := dbq.New(h.pool)
|
||
if _, err := q.GetAlbumByID(r.Context(), id); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
writeErr(w, http.StatusNotFound, "not_found", "album not found")
|
||
return
|
||
}
|
||
h.logger.Error("api: like album lookup", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: id}); err != nil {
|
||
h.logger.Error("api: like album insert", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (h *handlers) handleUnlikeAlbum(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id")
|
||
return
|
||
}
|
||
if err := dbq.New(h.pool).UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: id}); err != nil {
|
||
h.logger.Error("api: unlike album", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "delete failed")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (h *handlers) handleLikeArtist(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id")
|
||
return
|
||
}
|
||
q := dbq.New(h.pool)
|
||
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
writeErr(w, http.StatusNotFound, "not_found", "artist not found")
|
||
return
|
||
}
|
||
h.logger.Error("api: like artist lookup", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||
return
|
||
}
|
||
if err := q.LikeArtist(r.Context(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: id}); err != nil {
|
||
h.logger.Error("api: like artist insert", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (h *handlers) handleUnlikeArtist(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||
if !ok {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", "invalid artist id")
|
||
return
|
||
}
|
||
if err := dbq.New(h.pool).UnlikeArtist(r.Context(), dbq.UnlikeArtistParams{UserID: user.ID, ArtistID: id}); err != nil {
|
||
h.logger.Error("api: unlike artist", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "delete failed")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (h *handlers) handleListLikedTracks(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
limit, offset, err := parsePaging(r.URL.Query())
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||
return
|
||
}
|
||
q := dbq.New(h.pool)
|
||
rows, err := q.ListLikedTrackRows(r.Context(), dbq.ListLikedTrackRowsParams{
|
||
UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
|
||
})
|
||
if err != nil {
|
||
h.logger.Error("api: list liked tracks", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
total, err := q.CountLikedTracks(r.Context(), user.ID)
|
||
if err != nil {
|
||
h.logger.Error("api: count liked tracks", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
|
||
return
|
||
}
|
||
items, err := h.resolveTrackRefs(r.Context(), q, rows)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, Page[TrackRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
|
||
}
|
||
|
||
func (h *handlers) handleListLikedAlbums(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
limit, offset, err := parsePaging(r.URL.Query())
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||
return
|
||
}
|
||
q := dbq.New(h.pool)
|
||
rows, err := q.ListLikedAlbumRows(r.Context(), dbq.ListLikedAlbumRowsParams{
|
||
UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
|
||
})
|
||
if err != nil {
|
||
h.logger.Error("api: list liked albums", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
total, err := q.CountLikedAlbums(r.Context(), user.ID)
|
||
if err != nil {
|
||
h.logger.Error("api: count liked albums", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
|
||
return
|
||
}
|
||
items, err := h.resolveAlbumRefs(r.Context(), q, rows)
|
||
if err != nil {
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, Page[AlbumRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
|
||
}
|
||
|
||
func (h *handlers) handleListLikedArtists(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
limit, offset, err := parsePaging(r.URL.Query())
|
||
if err != nil {
|
||
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
|
||
return
|
||
}
|
||
q := dbq.New(h.pool)
|
||
rows, err := q.ListLikedArtistRows(r.Context(), dbq.ListLikedArtistRowsParams{
|
||
UserID: user.ID, Limit: int32(limit), Offset: int32(offset),
|
||
})
|
||
if err != nil {
|
||
h.logger.Error("api: list liked artists", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
total, err := q.CountLikedArtists(r.Context(), user.ID)
|
||
if err != nil {
|
||
h.logger.Error("api: count liked artists", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "count failed")
|
||
return
|
||
}
|
||
items := make([]ArtistRef, 0, len(rows))
|
||
for _, a := range rows {
|
||
albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID)
|
||
if aerr != nil {
|
||
h.logger.Error("api: list albums for artist", "err", aerr)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
items = append(items, artistRefFrom(a, len(albums)))
|
||
}
|
||
writeJSON(w, http.StatusOK, Page[ArtistRef]{Items: items, Total: int(total), Limit: limit, Offset: offset})
|
||
}
|
||
|
||
func (h *handlers) handleGetLikedIDs(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := auth.UserFromContext(r.Context())
|
||
if !ok {
|
||
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
||
return
|
||
}
|
||
q := dbq.New(h.pool)
|
||
trackIDs, err := q.ListLikedTrackIDs(r.Context(), user.ID)
|
||
if err != nil {
|
||
h.logger.Error("api: list liked track ids", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
albumIDs, err := q.ListLikedAlbumIDs(r.Context(), user.ID)
|
||
if err != nil {
|
||
h.logger.Error("api: list liked album ids", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
artistIDs, err := q.ListLikedArtistIDs(r.Context(), user.ID)
|
||
if err != nil {
|
||
h.logger.Error("api: list liked artist ids", "err", err)
|
||
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
|
||
return
|
||
}
|
||
resp := likedIDsResponse{
|
||
TrackIDs: uuidsToStrings(trackIDs),
|
||
AlbumIDs: uuidsToStrings(albumIDs),
|
||
ArtistIDs: uuidsToStrings(artistIDs),
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func uuidsToStrings(ids []pgtype.UUID) []string {
|
||
out := make([]string, 0, len(ids))
|
||
for _, id := range ids {
|
||
out = append(out, uuidToString(id))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Compile-time references so the json import is always live even when
|
||
// no handler in this file decodes a body.
|
||
var _ = json.Marshal
|
||
```
|
||
|
||
- [ ] **Step 4: Register the routes in `internal/api/api.go`**
|
||
|
||
Inside the `authed.Group(...)` block, after `authed.Post("/events", h.handleEvents)`:
|
||
|
||
```go
|
||
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
||
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
||
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
|
||
authed.Delete("/likes/albums/{id}", h.handleUnlikeAlbum)
|
||
authed.Post("/likes/artists/{id}", h.handleLikeArtist)
|
||
authed.Delete("/likes/artists/{id}", h.handleUnlikeArtist)
|
||
authed.Get("/likes/tracks", h.handleListLikedTracks)
|
||
authed.Get("/likes/albums", h.handleListLikedAlbums)
|
||
authed.Get("/likes/artists", h.handleListLikedArtists)
|
||
authed.Get("/likes/ids", h.handleGetLikedIDs)
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests, confirm pass**
|
||
|
||
Run:
|
||
```bash
|
||
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
||
go test ./internal/api -run TestLike -v
|
||
```
|
||
Expected: PASS — 7 tests.
|
||
|
||
- [ ] **Step 6: Lint + full Go suite**
|
||
|
||
Run: `golangci-lint run ./...`
|
||
Expected: clean.
|
||
|
||
Run:
|
||
```bash
|
||
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
||
go test -p 1 ./...
|
||
```
|
||
Expected: all packages PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add internal/api/likes.go internal/api/likes_test.go internal/api/api.go
|
||
git commit -m "feat(api): add /api/likes/{tracks,albums,artists} endpoints
|
||
|
||
Like/unlike are POST/DELETE on /api/likes/{type}/{id}; idempotent (204
|
||
on repeats). List endpoints return Page<TrackRef|AlbumRef|ArtistRef>
|
||
sorted liked_at DESC. /api/likes/ids returns flat id arrays for the
|
||
client-side heart-button cache."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Subsonic `/star` and `/unstar` handlers
|
||
|
||
**Files:**
|
||
- Create: `internal/subsonic/star.go`
|
||
- Create: `internal/subsonic/star_test.go` (will be extended in Task 4 with getStarred coverage)
|
||
- Modify: `internal/subsonic/subsonic.go` (register routes)
|
||
|
||
Validate-all-first atomicity: parse all `id`/`albumId`/`artistId` params, look up each entity; if any is missing return error 70 BEFORE any write.
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `internal/subsonic/star_test.go`:
|
||
|
||
```go
|
||
package subsonic
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||
)
|
||
|
||
func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album, dbq.Artist) {
|
||
t.Helper()
|
||
if testing.Short() {
|
||
t.Skip("skipping in -short mode")
|
||
}
|
||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||
if dsn == "" {
|
||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||
}
|
||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
pool, err := pgxpool.New(context.Background(), dsn)
|
||
if err != nil {
|
||
t.Fatalf("pool: %v", err)
|
||
}
|
||
t.Cleanup(pool.Close)
|
||
if _, err := pool.Exec(context.Background(),
|
||
"TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
||
t.Fatalf("truncate: %v", err)
|
||
}
|
||
q := dbq.New(pool)
|
||
u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||
Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
|
||
})
|
||
a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"})
|
||
al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID})
|
||
tr, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||
Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/star.flac", DurationMs: 100_000,
|
||
})
|
||
return pool, u, tr, al, a
|
||
}
|
||
|
||
func newStarHandlers(pool *pgxpool.Pool) *mediaHandlers {
|
||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||
return newMediaHandlers(pool, w)
|
||
}
|
||
|
||
func TestHandleStar_TrackIDInsertsRow(t *testing.T) {
|
||
pool, user, track, _, _ := testStarPool(t)
|
||
m := newStarHandlers(pool)
|
||
|
||
q := url.Values{}
|
||
q.Set("id", uuidToID(track.ID))
|
||
req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
|
||
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
||
w := httptest.NewRecorder()
|
||
m.handleStar(w, req.WithContext(ctx))
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", w.Code)
|
||
}
|
||
var count int
|
||
_ = pool.QueryRow(context.Background(),
|
||
"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
|
||
if count != 1 {
|
||
t.Errorf("count = %d", count)
|
||
}
|
||
}
|
||
|
||
func TestHandleStar_AllThreeIDsAtOnce(t *testing.T) {
|
||
pool, user, track, album, artist := testStarPool(t)
|
||
m := newStarHandlers(pool)
|
||
|
||
q := url.Values{}
|
||
q.Set("id", uuidToID(track.ID))
|
||
q.Set("albumId", uuidToID(album.ID))
|
||
q.Set("artistId", uuidToID(artist.ID))
|
||
req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
|
||
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
||
w := httptest.NewRecorder()
|
||
m.handleStar(w, req.WithContext(ctx))
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", w.Code)
|
||
}
|
||
var tCount, alCount, arCount int
|
||
_ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&tCount)
|
||
_ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_albums WHERE user_id=$1", user.ID).Scan(&alCount)
|
||
_ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_artists WHERE user_id=$1", user.ID).Scan(&arCount)
|
||
if tCount != 1 || alCount != 1 || arCount != 1 {
|
||
t.Errorf("counts: tracks=%d albums=%d artists=%d", tCount, alCount, arCount)
|
||
}
|
||
}
|
||
|
||
func TestHandleStar_UnknownTrackReturnsError70(t *testing.T) {
|
||
pool, user, _, _, _ := testStarPool(t)
|
||
m := newStarHandlers(pool)
|
||
|
||
q := url.Values{}
|
||
q.Set("id", "00000000-0000-0000-0000-000000000000")
|
||
req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
|
||
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
||
w := httptest.NewRecorder()
|
||
m.handleStar(w, req.WithContext(ctx))
|
||
|
||
body := w.Body.String()
|
||
// Subsonic error envelope returns 200 OK with status="failed" inside the body.
|
||
// The exact code is 70 (data not found).
|
||
if w.Code != http.StatusOK || !contains(body, `"code":70`) && !contains(body, `code="70"`) {
|
||
t.Errorf("expected error 70 envelope, got status=%d body=%s", w.Code, body)
|
||
}
|
||
}
|
||
|
||
func TestHandleStar_PartialMissingAtomic(t *testing.T) {
|
||
pool, user, track, _, _ := testStarPool(t)
|
||
m := newStarHandlers(pool)
|
||
|
||
q := url.Values{}
|
||
q.Set("id", uuidToID(track.ID))
|
||
q.Set("albumId", "00000000-0000-0000-0000-000000000000")
|
||
req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
|
||
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
||
w := httptest.NewRecorder()
|
||
m.handleStar(w, req.WithContext(ctx))
|
||
|
||
// Album missing → atomic refusal: track NOT starred.
|
||
var count int
|
||
_ = pool.QueryRow(context.Background(),
|
||
"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
|
||
if count != 0 {
|
||
t.Errorf("track was starred despite album miss; count = %d, want 0", count)
|
||
}
|
||
}
|
||
|
||
func TestHandleUnstar_RemovesAndIsIdempotent(t *testing.T) {
|
||
pool, user, track, _, _ := testStarPool(t)
|
||
m := newStarHandlers(pool)
|
||
|
||
// First star.
|
||
q := url.Values{}
|
||
q.Set("id", uuidToID(track.ID))
|
||
star := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
|
||
starCtx := context.WithValue(star.Context(), userCtxKey, user)
|
||
starResp := httptest.NewRecorder()
|
||
m.handleStar(starResp, star.WithContext(starCtx))
|
||
|
||
// Unstar.
|
||
un := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil)
|
||
unCtx := context.WithValue(un.Context(), userCtxKey, user)
|
||
unResp := httptest.NewRecorder()
|
||
m.handleUnstar(unResp, un.WithContext(unCtx))
|
||
|
||
if unResp.Code != http.StatusOK {
|
||
t.Fatalf("unstar status = %d", unResp.Code)
|
||
}
|
||
var count int
|
||
_ = pool.QueryRow(context.Background(),
|
||
"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
|
||
if count != 0 {
|
||
t.Errorf("count after unstar = %d, want 0", count)
|
||
}
|
||
|
||
// Idempotent — unstar again.
|
||
un2 := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil)
|
||
un2 = un2.WithContext(context.WithValue(un2.Context(), userCtxKey, user))
|
||
un2Resp := httptest.NewRecorder()
|
||
m.handleUnstar(un2Resp, un2)
|
||
if un2Resp.Code != http.StatusOK {
|
||
t.Errorf("second unstar status = %d", un2Resp.Code)
|
||
}
|
||
}
|
||
|
||
func contains(s, substr string) bool {
|
||
for i := 0; i+len(substr) <= len(s); i++ {
|
||
if s[i:i+len(substr)] == substr {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests, confirm fail**
|
||
|
||
Run:
|
||
```bash
|
||
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
||
go test ./internal/subsonic -run TestHandleStar -v
|
||
```
|
||
Expected: FAIL — `handleStar`/`handleUnstar` undefined.
|
||
|
||
- [ ] **Step 3: Implement `internal/subsonic/star.go`**
|
||
|
||
```go
|
||
package subsonic
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
// handleStar implements /rest/star. Accepts any combination of id (track),
|
||
// albumId, artistId. All entities are validated before any insert; a single
|
||
// missing entity fails the whole call with Subsonic error 70.
|
||
func (m *mediaHandlers) handleStar(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := UserFromContext(r.Context())
|
||
if !ok {
|
||
WriteFail(w, r, ErrGeneric, "Unauthenticated")
|
||
return
|
||
}
|
||
params := r.URL.Query()
|
||
q := dbq.New(m.pool)
|
||
trackID, err := resolveStarID(r.Context(), q, params.Get("id"), entityKindTrack)
|
||
if err != nil {
|
||
WriteFail(w, r, ErrDataNotFound, err.Error())
|
||
return
|
||
}
|
||
albumID, err := resolveStarID(r.Context(), q, params.Get("albumId"), entityKindAlbum)
|
||
if err != nil {
|
||
WriteFail(w, r, ErrDataNotFound, err.Error())
|
||
return
|
||
}
|
||
artistID, err := resolveStarID(r.Context(), q, params.Get("artistId"), entityKindArtist)
|
||
if err != nil {
|
||
WriteFail(w, r, ErrDataNotFound, err.Error())
|
||
return
|
||
}
|
||
if trackID.Valid {
|
||
if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil {
|
||
WriteFail(w, r, ErrGeneric, "Could not star track")
|
||
return
|
||
}
|
||
}
|
||
if albumID.Valid {
|
||
if err := q.LikeAlbum(r.Context(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: albumID}); err != nil {
|
||
WriteFail(w, r, ErrGeneric, "Could not star album")
|
||
return
|
||
}
|
||
}
|
||
if artistID.Valid {
|
||
if err := q.LikeArtist(r.Context(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artistID}); err != nil {
|
||
WriteFail(w, r, ErrGeneric, "Could not star artist")
|
||
return
|
||
}
|
||
}
|
||
Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
|
||
}
|
||
|
||
// handleUnstar mirrors handleStar but DELETEs. Missing entities are treated
|
||
// as a no-op rather than an error — Subsonic clients commonly call unstar
|
||
// on entities the server has never seen (cross-server library moves).
|
||
func (m *mediaHandlers) handleUnstar(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := UserFromContext(r.Context())
|
||
if !ok {
|
||
WriteFail(w, r, ErrGeneric, "Unauthenticated")
|
||
return
|
||
}
|
||
params := r.URL.Query()
|
||
q := dbq.New(m.pool)
|
||
if t, ok := parseUUID(params.Get("id")); ok {
|
||
_ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t})
|
||
}
|
||
if a, ok := parseUUID(params.Get("albumId")); ok {
|
||
_ = q.UnlikeAlbum(r.Context(), dbq.UnlikeAlbumParams{UserID: user.ID, AlbumID: a})
|
||
}
|
||
if a, ok := parseUUID(params.Get("artistId")); ok {
|
||
_ = q.UnlikeArtist(r.Context(), dbq.UnlikeArtistParams{UserID: user.ID, ArtistID: a})
|
||
}
|
||
Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
|
||
}
|
||
|
||
type entityKind int
|
||
|
||
const (
|
||
entityKindTrack entityKind = iota
|
||
entityKindAlbum
|
||
entityKindArtist
|
||
)
|
||
|
||
// resolveStarID returns (uuid, nil) if the param is empty (no-op), or
|
||
// (uuid, nil) if the entity exists, or (zero, error) if the entity is
|
||
// missing/malformed. Non-empty malformed UUID is treated as a 'not found'
|
||
// per Subsonic semantics.
|
||
func resolveStarID(ctx context.Context, q *dbq.Queries, raw string, kind entityKind) (pgtype.UUID, error) {
|
||
if raw == "" {
|
||
return pgtype.UUID{}, nil
|
||
}
|
||
id, ok := parseUUID(raw)
|
||
if !ok {
|
||
return pgtype.UUID{}, errors.New("not found")
|
||
}
|
||
switch kind {
|
||
case entityKindTrack:
|
||
if _, err := q.GetTrackByID(ctx, id); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return pgtype.UUID{}, errors.New("track not found")
|
||
}
|
||
return pgtype.UUID{}, err
|
||
}
|
||
case entityKindAlbum:
|
||
if _, err := q.GetAlbumByID(ctx, id); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return pgtype.UUID{}, errors.New("album not found")
|
||
}
|
||
return pgtype.UUID{}, err
|
||
}
|
||
case entityKindArtist:
|
||
if _, err := q.GetArtistByID(ctx, id); err != nil {
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return pgtype.UUID{}, errors.New("artist not found")
|
||
}
|
||
return pgtype.UUID{}, err
|
||
}
|
||
}
|
||
return id, nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Register the routes in `internal/subsonic/subsonic.go`**
|
||
|
||
Inside the `r.Route("/rest", ...)` block, after the existing `/scrobble` registration:
|
||
|
||
```go
|
||
register(sub, "/star", m.handleStar)
|
||
register(sub, "/unstar", m.handleUnstar)
|
||
```
|
||
|
||
(Lines for `/getStarred` and `/getStarred2` come in Task 4.)
|
||
|
||
- [ ] **Step 5: Run tests, confirm pass**
|
||
|
||
Run:
|
||
```bash
|
||
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
||
go test ./internal/subsonic -run TestHandleStar -v
|
||
```
|
||
Expected: PASS — 5 tests including `TestHandleUnstar_RemovesAndIsIdempotent`.
|
||
|
||
- [ ] **Step 6: Lint**
|
||
|
||
Run: `golangci-lint run ./internal/subsonic/...`
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add internal/subsonic/star.go internal/subsonic/star_test.go internal/subsonic/subsonic.go
|
||
git commit -m "feat(subsonic): add /rest/star and /rest/unstar handlers
|
||
|
||
Validate-all-first atomicity on star: a missing entity refuses the whole
|
||
call with Subsonic error 70. Unstar is a best-effort delete (missing
|
||
entities are no-ops, matching client expectations after library moves)."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Subsonic `/getStarred` and `/getStarred2`
|
||
|
||
**Files:**
|
||
- Modify: `internal/subsonic/star.go` (add the two handlers)
|
||
- Modify: `internal/subsonic/star_test.go` (extend with getStarred tests)
|
||
- Modify: `internal/subsonic/subsonic.go` (register routes)
|
||
- Modify: `internal/subsonic/types.go` (add `StarredContainer` and response types)
|
||
|
||
- [ ] **Step 1: Add tests for getStarred2**
|
||
|
||
Append to `internal/subsonic/star_test.go`:
|
||
|
||
```go
|
||
import "encoding/json"
|
||
|
||
func TestHandleGetStarred2_ReturnsAllThreeArrays(t *testing.T) {
|
||
pool, user, track, album, artist := testStarPool(t)
|
||
m := newStarHandlers(pool)
|
||
q := dbq.New(pool)
|
||
|
||
// Star one of each.
|
||
_ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: user.ID, TrackID: track.ID})
|
||
_ = q.LikeAlbum(context.Background(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: album.ID})
|
||
_ = q.LikeArtist(context.Background(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artist.ID})
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil)
|
||
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
||
w := httptest.NewRecorder()
|
||
m.handleGetStarred2(w, req.WithContext(ctx))
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
// Subsonic JSON envelope: {"subsonic-response":{...,"starred2":{...}}}
|
||
var raw map[string]any
|
||
_ = json.Unmarshal(w.Body.Bytes(), &raw)
|
||
resp, _ := raw["subsonic-response"].(map[string]any)
|
||
starred, _ := resp["starred2"].(map[string]any)
|
||
if starred == nil {
|
||
t.Fatalf("missing starred2 envelope: %v", raw)
|
||
}
|
||
songs, _ := starred["song"].([]any)
|
||
albums, _ := starred["album"].([]any)
|
||
artists, _ := starred["artist"].([]any)
|
||
if len(songs) != 1 || len(albums) != 1 || len(artists) != 1 {
|
||
t.Errorf("counts: songs=%d albums=%d artists=%d", len(songs), len(albums), len(artists))
|
||
}
|
||
}
|
||
|
||
func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) {
|
||
pool, alice, track, _, _ := testStarPool(t)
|
||
q := dbq.New(pool)
|
||
bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||
Username: "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
|
||
})
|
||
_ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID})
|
||
|
||
m := newStarHandlers(pool)
|
||
req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil)
|
||
ctx := context.WithValue(req.Context(), userCtxKey, bob)
|
||
w := httptest.NewRecorder()
|
||
m.handleGetStarred2(w, req.WithContext(ctx))
|
||
|
||
var raw map[string]any
|
||
_ = json.Unmarshal(w.Body.Bytes(), &raw)
|
||
resp, _ := raw["subsonic-response"].(map[string]any)
|
||
starred, _ := resp["starred2"].(map[string]any)
|
||
songs, _ := starred["song"].([]any)
|
||
if len(songs) != 0 {
|
||
t.Errorf("bob's starred songs should be empty, got %d", len(songs))
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add the type containers in `internal/subsonic/types.go`**
|
||
|
||
After `SearchResult3Container` and its response wrapper, add:
|
||
|
||
```go
|
||
// StarredContainer is the body of getStarred / getStarred2.
|
||
type StarredContainer struct {
|
||
XMLName xml.Name `json:"-" xml:"starred2"`
|
||
Artists []ArtistRef `json:"artist" xml:"artist"`
|
||
Albums []AlbumRef `json:"album" xml:"album"`
|
||
Songs []SongRef `json:"song" xml:"song"`
|
||
}
|
||
|
||
type GetStarred2Response struct {
|
||
Envelope
|
||
Starred2 StarredContainer `json:"starred2" xml:"starred2"`
|
||
}
|
||
|
||
// GetStarredContainer mirrors getStarred (id3-less variant). Response uses
|
||
// the same shape; the only material difference from getStarred2 is the
|
||
// outer key name.
|
||
type GetStarredContainer struct {
|
||
XMLName xml.Name `json:"-" xml:"starred"`
|
||
Artists []ArtistRef `json:"artist" xml:"artist"`
|
||
Albums []AlbumRef `json:"album" xml:"album"`
|
||
Songs []SongRef `json:"song" xml:"song"`
|
||
}
|
||
|
||
type GetStarredResponse struct {
|
||
Envelope
|
||
Starred GetStarredContainer `json:"starred" xml:"starred"`
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Implement the handlers in `internal/subsonic/star.go`**
|
||
|
||
Append:
|
||
|
||
```go
|
||
func (m *mediaHandlers) handleGetStarred(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := UserFromContext(r.Context())
|
||
if !ok {
|
||
WriteFail(w, r, ErrGeneric, "Unauthenticated")
|
||
return
|
||
}
|
||
q := dbq.New(m.pool)
|
||
songs, albums, artists, err := loadStarred(r.Context(), q, user.ID)
|
||
if err != nil {
|
||
WriteFail(w, r, ErrGeneric, "Could not load starred")
|
||
return
|
||
}
|
||
Write(w, r, GetStarredResponse{
|
||
Envelope: NewEnvelope("ok"),
|
||
Starred: GetStarredContainer{
|
||
Artists: artists,
|
||
Albums: albums,
|
||
Songs: songs,
|
||
},
|
||
})
|
||
}
|
||
|
||
func (m *mediaHandlers) handleGetStarred2(w http.ResponseWriter, r *http.Request) {
|
||
user, ok := UserFromContext(r.Context())
|
||
if !ok {
|
||
WriteFail(w, r, ErrGeneric, "Unauthenticated")
|
||
return
|
||
}
|
||
q := dbq.New(m.pool)
|
||
songs, albums, artists, err := loadStarred(r.Context(), q, user.ID)
|
||
if err != nil {
|
||
WriteFail(w, r, ErrGeneric, "Could not load starred")
|
||
return
|
||
}
|
||
Write(w, r, GetStarred2Response{
|
||
Envelope: NewEnvelope("ok"),
|
||
Starred2: StarredContainer{
|
||
Artists: artists,
|
||
Albums: albums,
|
||
Songs: songs,
|
||
},
|
||
})
|
||
}
|
||
|
||
// loadStarred fetches the user's full starred set in liked_at DESC order,
|
||
// projects to Subsonic refs (Song / Album / Artist).
|
||
func loadStarred(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]SongRef, []AlbumRef, []ArtistRef, error) {
|
||
const cap = 500 // bounded for safety; M2 doesn't paginate getStarred
|
||
songs := []SongRef{}
|
||
albums := []AlbumRef{}
|
||
artists := []ArtistRef{}
|
||
|
||
trackRows, err := q.ListLikedTrackRows(ctx, dbq.ListLikedTrackRowsParams{
|
||
UserID: userID, Limit: cap, Offset: 0,
|
||
})
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
for _, t := range trackRows {
|
||
album, err := q.GetAlbumByID(ctx, t.AlbumID)
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
artist, err := q.GetArtistByID(ctx, t.ArtistID)
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
songs = append(songs, songRef(t, album.Title, artist.Name))
|
||
}
|
||
|
||
albumRows, err := q.ListLikedAlbumRows(ctx, dbq.ListLikedAlbumRowsParams{
|
||
UserID: userID, Limit: cap, Offset: 0,
|
||
})
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
for _, a := range albumRows {
|
||
artist, err := q.GetArtistByID(ctx, a.ArtistID)
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
count, err := q.CountTracksByAlbum(ctx, a.ID)
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
albums = append(albums, albumRef(a, artist.Name, int(count), 0))
|
||
}
|
||
|
||
artistRows, err := q.ListLikedArtistRows(ctx, dbq.ListLikedArtistRowsParams{
|
||
UserID: userID, Limit: cap, Offset: 0,
|
||
})
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
for _, a := range artistRows {
|
||
albums, err := q.ListAlbumsByArtist(ctx, a.ID)
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
artists = append(artists, artistRef(a, len(albums)))
|
||
}
|
||
|
||
return songs, albums, artists, nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Register the routes in `internal/subsonic/subsonic.go`**
|
||
|
||
After the `register(sub, "/star", ...)` and `/unstar` lines, add:
|
||
|
||
```go
|
||
register(sub, "/getStarred", m.handleGetStarred)
|
||
register(sub, "/getStarred2", m.handleGetStarred2)
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests, confirm pass**
|
||
|
||
Run:
|
||
```bash
|
||
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
||
go test ./internal/subsonic -v
|
||
```
|
||
Expected: PASS — including the 2 new getStarred tests + the 5 from Task 3 + existing scrobble + browse coverage.
|
||
|
||
- [ ] **Step 6: Full Go suite + lint**
|
||
|
||
Run:
|
||
```bash
|
||
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
||
go test -p 1 ./...
|
||
```
|
||
Expected: all packages PASS.
|
||
|
||
Run: `golangci-lint run ./...`
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add internal/subsonic/star.go internal/subsonic/star_test.go internal/subsonic/subsonic.go internal/subsonic/types.go
|
||
git commit -m "feat(subsonic): add getStarred and getStarred2 handlers
|
||
|
||
Both return user's starred artists/albums/songs sorted liked_at DESC.
|
||
Cap at 500 entries per category for v1 (Subsonic spec doesn't define
|
||
pagination on these endpoints; M3+ can revisit if needed)."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Web client — types + likes.ts (TanStack Query helpers)
|
||
|
||
**Files:**
|
||
- Modify: `web/src/lib/api/types.ts`
|
||
- Modify: `web/src/lib/api/queries.ts`
|
||
- Create: `web/src/lib/api/likes.ts`
|
||
- Create: `web/src/lib/api/likes.test.ts`
|
||
|
||
- [ ] **Step 1: Add types**
|
||
|
||
Append to `web/src/lib/api/types.ts`:
|
||
|
||
```ts
|
||
export type LikedIdsResponse = {
|
||
track_ids: string[];
|
||
album_ids: string[];
|
||
artist_ids: string[];
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 2: Add qk keys**
|
||
|
||
Modify `web/src/lib/api/queries.ts`'s `qk` object — add four new entries:
|
||
|
||
```ts
|
||
export const qk = {
|
||
artists: (sort: ArtistSort) => ['artists', { sort }] as const,
|
||
artist: (id: string) => ['artist', id] as const,
|
||
album: (id: string) => ['album', id] as const,
|
||
search: (q: string) => ['search', { q }] as const,
|
||
searchArtists: (q: string) => ['searchArtists', { q }] as const,
|
||
searchAlbums: (q: string) => ['searchAlbums', { q }] as const,
|
||
searchTracks: (q: string) => ['searchTracks', { q }] as const,
|
||
likedIds: () => ['likedIds'] as const,
|
||
likedTracks: () => ['likedTracks'] as const,
|
||
likedAlbums: () => ['likedAlbums'] as const,
|
||
likedArtists: () => ['likedArtists'] as const,
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: Write the failing tests**
|
||
|
||
Create `web/src/lib/api/likes.test.ts`:
|
||
|
||
```ts
|
||
import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
|
||
import { QueryClient } from '@tanstack/svelte-query';
|
||
|
||
vi.mock('./client', () => ({
|
||
api: {
|
||
get: vi.fn(),
|
||
post: vi.fn().mockResolvedValue(null),
|
||
del: vi.fn().mockResolvedValue(null)
|
||
}
|
||
}));
|
||
|
||
import { likeEntity, unlikeEntity } from './likes';
|
||
import { qk } from './queries';
|
||
import { api } from './client';
|
||
|
||
let client: QueryClient;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
client = new QueryClient();
|
||
client.setQueryData(qk.likedIds(), {
|
||
track_ids: ['t1'],
|
||
album_ids: [],
|
||
artist_ids: []
|
||
});
|
||
});
|
||
|
||
afterEach(() => client.clear());
|
||
|
||
describe('likes mutations', () => {
|
||
test('likeEntity track adds id to track_ids in cache (optimistic)', async () => {
|
||
await likeEntity(client, 'track', 't2');
|
||
const cached = client.getQueryData(qk.likedIds()) as
|
||
| { track_ids: string[] } | undefined;
|
||
expect(cached?.track_ids).toContain('t2');
|
||
expect(api.post).toHaveBeenCalledWith('/api/likes/tracks/t2');
|
||
});
|
||
|
||
test('likeEntity album hits albums endpoint and adds to album_ids', async () => {
|
||
await likeEntity(client, 'album', 'al1');
|
||
const cached = client.getQueryData(qk.likedIds()) as
|
||
| { album_ids: string[] } | undefined;
|
||
expect(cached?.album_ids).toContain('al1');
|
||
expect(api.post).toHaveBeenCalledWith('/api/likes/albums/al1');
|
||
});
|
||
|
||
test('likeEntity artist adds to artist_ids', async () => {
|
||
await likeEntity(client, 'artist', 'ar1');
|
||
const cached = client.getQueryData(qk.likedIds()) as
|
||
| { artist_ids: string[] } | undefined;
|
||
expect(cached?.artist_ids).toContain('ar1');
|
||
expect(api.post).toHaveBeenCalledWith('/api/likes/artists/ar1');
|
||
});
|
||
|
||
test('unlikeEntity removes id from cache', async () => {
|
||
await unlikeEntity(client, 'track', 't1');
|
||
const cached = client.getQueryData(qk.likedIds()) as
|
||
| { track_ids: string[] } | undefined;
|
||
expect(cached?.track_ids).not.toContain('t1');
|
||
expect(api.del).toHaveBeenCalledWith('/api/likes/tracks/t1');
|
||
});
|
||
|
||
test('likeEntity rolls back on error', async () => {
|
||
(api.post as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('boom'));
|
||
try {
|
||
await likeEntity(client, 'track', 't2');
|
||
} catch { /* expected */ }
|
||
const cached = client.getQueryData(qk.likedIds()) as
|
||
| { track_ids: string[] } | undefined;
|
||
expect(cached?.track_ids).not.toContain('t2');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm fail**
|
||
|
||
Run: `cd web && npm test -- src/lib/api/likes.test.ts`
|
||
Expected: FAIL — `./likes` module not found.
|
||
|
||
- [ ] **Step 5: Implement `web/src/lib/api/likes.ts`**
|
||
|
||
```ts
|
||
import type { QueryClient } from '@tanstack/svelte-query';
|
||
import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
|
||
import { api } from './client';
|
||
import { qk, ARTIST_PAGE_SIZE } from './queries';
|
||
import type {
|
||
ArtistRef,
|
||
AlbumRef,
|
||
TrackRef,
|
||
Page,
|
||
LikedIdsResponse
|
||
} from './types';
|
||
|
||
export type EntityKind = 'track' | 'album' | 'artist';
|
||
|
||
const PATH_BY_KIND: Record<EntityKind, 'tracks' | 'albums' | 'artists'> = {
|
||
track: 'tracks',
|
||
album: 'albums',
|
||
artist: 'artists'
|
||
};
|
||
|
||
const SET_KEY_BY_KIND: Record<EntityKind, keyof LikedIdsResponse> = {
|
||
track: 'track_ids',
|
||
album: 'album_ids',
|
||
artist: 'artist_ids'
|
||
};
|
||
|
||
export function createLikedIdsQuery() {
|
||
return createQuery({
|
||
queryKey: qk.likedIds(),
|
||
queryFn: () => api.get<LikedIdsResponse>('/api/likes/ids'),
|
||
staleTime: 60_000
|
||
});
|
||
}
|
||
|
||
export function createLikedTracksInfiniteQuery() {
|
||
return createInfiniteQuery({
|
||
queryKey: qk.likedTracks(),
|
||
queryFn: ({ pageParam = 0 }) =>
|
||
api.get<Page<TrackRef>>(`/api/likes/tracks?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`),
|
||
initialPageParam: 0,
|
||
getNextPageParam: (last) => {
|
||
const loaded = last.offset + last.items.length;
|
||
return loaded >= last.total ? undefined : loaded;
|
||
}
|
||
});
|
||
}
|
||
|
||
export function createLikedAlbumsInfiniteQuery() {
|
||
return createInfiniteQuery({
|
||
queryKey: qk.likedAlbums(),
|
||
queryFn: ({ pageParam = 0 }) =>
|
||
api.get<Page<AlbumRef>>(`/api/likes/albums?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`),
|
||
initialPageParam: 0,
|
||
getNextPageParam: (last) => {
|
||
const loaded = last.offset + last.items.length;
|
||
return loaded >= last.total ? undefined : loaded;
|
||
}
|
||
});
|
||
}
|
||
|
||
export function createLikedArtistsInfiniteQuery() {
|
||
return createInfiniteQuery({
|
||
queryKey: qk.likedArtists(),
|
||
queryFn: ({ pageParam = 0 }) =>
|
||
api.get<Page<ArtistRef>>(`/api/likes/artists?limit=${ARTIST_PAGE_SIZE}&offset=${pageParam}`),
|
||
initialPageParam: 0,
|
||
getNextPageParam: (last) => {
|
||
const loaded = last.offset + last.items.length;
|
||
return loaded >= last.total ? undefined : loaded;
|
||
}
|
||
});
|
||
}
|
||
|
||
// likeEntity does an optimistic insert into the likedIds cache, fires the
|
||
// network call, and rolls back on error. Same shape for unlikeEntity.
|
||
export async function likeEntity(
|
||
client: QueryClient,
|
||
kind: EntityKind,
|
||
id: string
|
||
): Promise<void> {
|
||
const setKey = SET_KEY_BY_KIND[kind];
|
||
const previous = client.getQueryData<LikedIdsResponse>(qk.likedIds());
|
||
if (previous) {
|
||
client.setQueryData<LikedIdsResponse>(qk.likedIds(), {
|
||
...previous,
|
||
[setKey]: previous[setKey].includes(id) ? previous[setKey] : [...previous[setKey], id]
|
||
});
|
||
}
|
||
try {
|
||
await api.post(`/api/likes/${PATH_BY_KIND[kind]}/${id}`, undefined);
|
||
} catch (err) {
|
||
if (previous) client.setQueryData(qk.likedIds(), previous);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
export async function unlikeEntity(
|
||
client: QueryClient,
|
||
kind: EntityKind,
|
||
id: string
|
||
): Promise<void> {
|
||
const setKey = SET_KEY_BY_KIND[kind];
|
||
const previous = client.getQueryData<LikedIdsResponse>(qk.likedIds());
|
||
if (previous) {
|
||
client.setQueryData<LikedIdsResponse>(qk.likedIds(), {
|
||
...previous,
|
||
[setKey]: previous[setKey].filter((x) => x !== id)
|
||
});
|
||
}
|
||
try {
|
||
await api.del(`/api/likes/${PATH_BY_KIND[kind]}/${id}`);
|
||
} catch (err) {
|
||
if (previous) client.setQueryData(qk.likedIds(), previous);
|
||
throw err;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests, confirm pass**
|
||
|
||
Run: `cd web && npm test -- src/lib/api/likes.test.ts`
|
||
Expected: PASS — 5 tests.
|
||
|
||
- [ ] **Step 7: Type-check**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: `0 ERRORS 0 WARNINGS`.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add web/src/lib/api/types.ts web/src/lib/api/queries.ts web/src/lib/api/likes.ts web/src/lib/api/likes.test.ts
|
||
git commit -m "feat(web): add likes TanStack Query helpers + mutations
|
||
|
||
createLikedIdsQuery is the single source for heart-button state across
|
||
the app (track_ids/album_ids/artist_ids Sets). likeEntity / unlikeEntity
|
||
do optimistic setQueryData updates with rollback on error. Per-entity
|
||
infinite queries back the /library/liked sections."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `LikeButton.svelte` component
|
||
|
||
**Files:**
|
||
- Create: `web/src/lib/components/LikeButton.svelte`
|
||
- Create: `web/src/lib/components/LikeButton.test.ts`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `web/src/lib/components/LikeButton.test.ts`:
|
||
|
||
```ts
|
||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||
import { readable } from 'svelte/store';
|
||
|
||
const cacheState = vi.hoisted(() => ({
|
||
data: { track_ids: ['t1'], album_ids: [], artist_ids: [] }
|
||
}));
|
||
|
||
vi.mock('$lib/api/likes', () => ({
|
||
createLikedIdsQuery: () => readable({ data: cacheState.data, isPending: false, isError: false }),
|
||
likeEntity: vi.fn().mockResolvedValue(undefined),
|
||
unlikeEntity: vi.fn().mockResolvedValue(undefined)
|
||
}));
|
||
|
||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||
const actual = (await orig()) as Record<string, unknown>;
|
||
return {
|
||
...actual,
|
||
useQueryClient: () => ({})
|
||
};
|
||
});
|
||
|
||
import LikeButton from './LikeButton.svelte';
|
||
import { likeEntity, unlikeEntity } from '$lib/api/likes';
|
||
|
||
afterEach(() => {
|
||
vi.clearAllMocks();
|
||
cacheState.data = { track_ids: ['t1'], album_ids: [], artist_ids: [] };
|
||
});
|
||
|
||
describe('LikeButton', () => {
|
||
test('renders filled heart when track id IS in the set', () => {
|
||
render(LikeButton, { props: { entityType: 'track', entityId: 't1' } });
|
||
const btn = screen.getByRole('button', { name: /unlike/i });
|
||
expect(btn.getAttribute('aria-pressed')).toBe('true');
|
||
});
|
||
|
||
test('renders empty heart when track id is NOT in the set', () => {
|
||
render(LikeButton, { props: { entityType: 'track', entityId: 't2' } });
|
||
const btn = screen.getByRole('button', { name: /like/i });
|
||
expect(btn.getAttribute('aria-pressed')).toBe('false');
|
||
});
|
||
|
||
test('click on empty heart calls likeEntity', async () => {
|
||
render(LikeButton, { props: { entityType: 'track', entityId: 't2' } });
|
||
await fireEvent.click(screen.getByRole('button', { name: /like/i }));
|
||
expect(likeEntity).toHaveBeenCalledWith(expect.anything(), 'track', 't2');
|
||
});
|
||
|
||
test('click on filled heart calls unlikeEntity', async () => {
|
||
render(LikeButton, { props: { entityType: 'track', entityId: 't1' } });
|
||
await fireEvent.click(screen.getByRole('button', { name: /unlike/i }));
|
||
expect(unlikeEntity).toHaveBeenCalledWith(expect.anything(), 'track', 't1');
|
||
});
|
||
|
||
test('click stops propagation', async () => {
|
||
const parentClick = vi.fn();
|
||
const { container } = render(LikeButton, { props: { entityType: 'track', entityId: 't2' } });
|
||
const wrapper = container.parentElement!;
|
||
wrapper.addEventListener('click', parentClick);
|
||
await fireEvent.click(screen.getByRole('button', { name: /like/i }));
|
||
expect(parentClick).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('album entityType reads from album_ids set', () => {
|
||
cacheState.data = { track_ids: [], album_ids: ['al1'], artist_ids: [] };
|
||
render(LikeButton, { props: { entityType: 'album', entityId: 'al1' } });
|
||
expect(screen.getByRole('button', { name: /unlike/i }).getAttribute('aria-pressed')).toBe('true');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm fail**
|
||
|
||
Run: `cd web && npm test -- src/lib/components/LikeButton.test.ts`
|
||
Expected: FAIL — component doesn't exist.
|
||
|
||
- [ ] **Step 3: Implement `web/src/lib/components/LikeButton.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import { useQueryClient } from '@tanstack/svelte-query';
|
||
import { createLikedIdsQuery, likeEntity, unlikeEntity, type EntityKind } from '$lib/api/likes';
|
||
|
||
let {
|
||
entityType,
|
||
entityId
|
||
}: { entityType: EntityKind; entityId: string } = $props();
|
||
|
||
const SET_KEY = {
|
||
track: 'track_ids',
|
||
album: 'album_ids',
|
||
artist: 'artist_ids'
|
||
} as const;
|
||
|
||
const queryClient = useQueryClient();
|
||
const queryStore = $derived(createLikedIdsQuery());
|
||
const query = $derived($queryStore);
|
||
|
||
const liked = $derived.by(() => {
|
||
const data = query?.data;
|
||
if (!data) return false;
|
||
return data[SET_KEY[entityType]].includes(entityId);
|
||
});
|
||
|
||
async function onClick(e: MouseEvent) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
if (liked) {
|
||
await unlikeEntity(queryClient, entityType, entityId);
|
||
} else {
|
||
await likeEntity(queryClient, entityType, entityId);
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<button
|
||
type="button"
|
||
aria-label={liked ? 'Unlike' : 'Like'}
|
||
aria-pressed={liked}
|
||
onclick={onClick}
|
||
class="rounded p-1 {liked ? 'text-accent' : 'text-text-secondary hover:text-text-primary'}"
|
||
>{liked ? '♥' : '♡'}</button>
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm pass**
|
||
|
||
Run: `cd web && npm test -- src/lib/components/LikeButton.test.ts`
|
||
Expected: PASS — 6 tests.
|
||
|
||
- [ ] **Step 5: Type-check**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: `0 ERRORS 0 WARNINGS`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add web/src/lib/components/LikeButton.svelte web/src/lib/components/LikeButton.test.ts
|
||
git commit -m "feat(web): add LikeButton component
|
||
|
||
Heart icon reading from createLikedIdsQuery; click toggles via
|
||
likeEntity/unlikeEntity. Click stops propagation so it can nest
|
||
inside TrackRow's role=button div without triggering row activation."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Wire `LikeButton` into existing components
|
||
|
||
**Files:**
|
||
- Modify: `web/src/lib/components/TrackRow.svelte` + `TrackRow.test.ts`
|
||
- Modify: `web/src/lib/components/AlbumCard.svelte` + `AlbumCard.test.ts`
|
||
- Modify: `web/src/lib/components/ArtistRow.svelte` + (no test file currently — skip)
|
||
- Modify: `web/src/lib/components/PlayerBar.svelte` + `PlayerBar.test.ts`
|
||
- Modify: `web/src/lib/components/Shell.svelte`
|
||
|
||
`LikeButton` mounts inside contexts that already mock the query layer; the test files need their existing `vi.mock('$lib/api/likes', ...)` set up so `LikeButton` doesn't crash during test render.
|
||
|
||
- [ ] **Step 1: Update `TrackRow.svelte`**
|
||
|
||
Replace the file:
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import type { TrackRef } from '$lib/api/types';
|
||
import { formatDuration } from '$lib/media/duration';
|
||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||
import LikeButton from './LikeButton.svelte';
|
||
|
||
type PlayHandler = (tracks: TrackRef[], index: number) => void;
|
||
|
||
let {
|
||
tracks,
|
||
index,
|
||
onPlay
|
||
}: { tracks: TrackRef[]; index: number; onPlay?: PlayHandler } = $props();
|
||
|
||
const track = $derived(tracks[index]);
|
||
|
||
function activate() {
|
||
if (onPlay) onPlay(tracks, index);
|
||
else playQueue(tracks, index);
|
||
}
|
||
|
||
function onRowClick() {
|
||
activate();
|
||
}
|
||
|
||
function onRowKey(e: KeyboardEvent) {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
activate();
|
||
}
|
||
}
|
||
|
||
function onAddClick(e: MouseEvent) {
|
||
e.stopPropagation();
|
||
enqueueTrack(track);
|
||
}
|
||
</script>
|
||
|
||
<div
|
||
role="button"
|
||
tabindex="0"
|
||
aria-label={track.title}
|
||
onclick={onRowClick}
|
||
onkeydown={onRowKey}
|
||
class="grid w-full cursor-pointer grid-cols-[32px_1fr_auto_auto_auto] items-center gap-4 px-3 py-2 text-left text-sm odd:bg-surface/50 hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||
>
|
||
<span class="text-right tabular-nums text-text-secondary">
|
||
{track.track_number ?? '—'}
|
||
</span>
|
||
<span class="truncate">{track.title}</span>
|
||
<LikeButton entityType="track" entityId={track.id} />
|
||
<button
|
||
type="button"
|
||
aria-label="Add to queue"
|
||
onclick={onAddClick}
|
||
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
||
>+</button>
|
||
<span class="tabular-nums text-text-secondary">{formatDuration(track.duration_sec)}</span>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 2: Update `TrackRow.test.ts`**
|
||
|
||
Add to the top of the file (after the existing `vi.mock('$lib/player/store.svelte', ...)` block):
|
||
|
||
```ts
|
||
vi.mock('$lib/api/likes', () => ({
|
||
createLikedIdsQuery: () => readable({
|
||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||
isPending: false,
|
||
isError: false
|
||
}),
|
||
likeEntity: vi.fn(),
|
||
unlikeEntity: vi.fn()
|
||
}));
|
||
|
||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||
const actual = (await orig()) as Record<string, unknown>;
|
||
return { ...actual, useQueryClient: () => ({}) };
|
||
});
|
||
```
|
||
|
||
Add `import { readable } from 'svelte/store';` at the top.
|
||
|
||
- [ ] **Step 3: Update `AlbumCard.svelte`**
|
||
|
||
Replace:
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
||
import { FALLBACK_COVER } from '$lib/media/covers';
|
||
import { api } from '$lib/api/client';
|
||
import { enqueueTracks } from '$lib/player/store.svelte';
|
||
import LikeButton from './LikeButton.svelte';
|
||
|
||
let { album }: { album: AlbumRef } = $props();
|
||
|
||
function onImgError(e: Event) {
|
||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
||
}
|
||
|
||
async function onAddClick(e: MouseEvent) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const detail = await api.get<AlbumDetail>(`/api/albums/${album.id}`);
|
||
enqueueTracks(detail.tracks);
|
||
}
|
||
</script>
|
||
|
||
<div class="relative">
|
||
<a
|
||
href={`/albums/${album.id}`}
|
||
class="group block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
||
>
|
||
<img
|
||
src={album.cover_url}
|
||
alt=""
|
||
class="aspect-square w-full rounded object-cover transition-transform group-hover:scale-[1.03]"
|
||
loading="lazy"
|
||
onerror={onImgError}
|
||
/>
|
||
<div class="mt-2 truncate text-sm font-medium">{album.title}</div>
|
||
{#if album.year}
|
||
<div class="text-xs text-text-secondary">{album.year}</div>
|
||
{/if}
|
||
</a>
|
||
<div class="absolute right-2 top-2 flex gap-1">
|
||
<LikeButton entityType="album" entityId={album.id} />
|
||
<button
|
||
type="button"
|
||
aria-label="Add to queue"
|
||
onclick={onAddClick}
|
||
class="rounded-full bg-surface/80 px-2 py-1 text-sm text-text-secondary hover:text-text-primary focus-visible:ring-2 focus-visible:ring-accent"
|
||
>+</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
Update `AlbumCard.test.ts` with the same likes/query-client mocks as TrackRow.test.ts.
|
||
|
||
- [ ] **Step 4: Update `ArtistRow.svelte`**
|
||
|
||
Replace:
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import type { ArtistRef } from '$lib/api/types';
|
||
import LikeButton from './LikeButton.svelte';
|
||
|
||
let { artist }: { artist: ArtistRef } = $props();
|
||
|
||
const initial = $derived(artist.name ? artist.name.charAt(0).toUpperCase() : '');
|
||
const countLabel = $derived(artist.album_count === 1 ? '1 album' : `${artist.album_count} albums`);
|
||
</script>
|
||
|
||
<div class="relative flex items-center gap-3 border-b border-border px-3 py-3 hover:bg-surface-hover">
|
||
<a
|
||
href={`/artists/${artist.id}`}
|
||
class="absolute inset-0 focus-visible:ring-2 focus-visible:ring-accent"
|
||
aria-label={artist.name}
|
||
></a>
|
||
<span
|
||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface font-semibold relative"
|
||
aria-hidden="true"
|
||
>
|
||
{initial}
|
||
</span>
|
||
<span class="flex-1 truncate relative">{artist.name}</span>
|
||
<span class="text-sm text-text-secondary relative">{countLabel}</span>
|
||
<span class="relative">
|
||
<LikeButton entityType="artist" entityId={artist.id} />
|
||
</span>
|
||
<span class="text-text-secondary relative" aria-hidden="true">›</span>
|
||
</div>
|
||
```
|
||
|
||
Note the structural change: previously ArtistRow was a single `<a>`. Now it's a `<div>` with the link as a positioned overlay so the LikeButton can sit above it without nesting `<button>` inside `<a>`. The link still spans the entire row visually.
|
||
|
||
- [ ] **Step 5: Update `PlayerBar.svelte`**
|
||
|
||
Find the left cluster (cover + title + artist link) and add the LikeButton:
|
||
|
||
```svelte
|
||
<!-- Existing left cluster — modify it: -->
|
||
<div class="flex w-60 min-w-0 items-center gap-3">
|
||
<a href={`/albums/${current.album_id}`} class="shrink-0 ..."> ... cover ... </a>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="truncate text-sm font-medium">{current.title}</div>
|
||
<a href={`/artists/${current.artist_id}`} class="...">{current.artist_name}</a>
|
||
</div>
|
||
<LikeButton entityType="track" entityId={current.id} />
|
||
</div>
|
||
```
|
||
|
||
Add `import LikeButton from './LikeButton.svelte';` at the top of the script.
|
||
|
||
Update `PlayerBar.test.ts` with the same likes/query-client mocks.
|
||
|
||
- [ ] **Step 6: Update `Shell.svelte` nav**
|
||
|
||
In the `navItems` array:
|
||
|
||
```ts
|
||
const navItems = [
|
||
{ href: '/', label: 'Library' },
|
||
{ href: '/library/liked', label: 'Liked' },
|
||
{ href: '/search', label: 'Search' },
|
||
{ href: '/playlists', label: 'Playlists' }
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 7: Run all affected tests**
|
||
|
||
Run: `cd web && npm test -- src/lib/components/`
|
||
Expected: PASS for TrackRow.test.ts, AlbumCard.test.ts, PlayerBar.test.ts, Shell.test.ts, LikeButton.test.ts. The component tests now mock the likes layer; the assertions on existing behavior (like click → playQueue) still pass.
|
||
|
||
- [ ] **Step 8: Type-check**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: `0 ERRORS 0 WARNINGS`.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add web/src/lib/components/TrackRow.svelte \
|
||
web/src/lib/components/TrackRow.test.ts \
|
||
web/src/lib/components/AlbumCard.svelte \
|
||
web/src/lib/components/AlbumCard.test.ts \
|
||
web/src/lib/components/ArtistRow.svelte \
|
||
web/src/lib/components/PlayerBar.svelte \
|
||
web/src/lib/components/PlayerBar.test.ts \
|
||
web/src/lib/components/Shell.svelte
|
||
git commit -m "feat(web): wire LikeButton into TrackRow, AlbumCard, ArtistRow, PlayerBar
|
||
|
||
Heart appears on every entity rendering surface. ArtistRow restructured
|
||
to a <div> with the link as a positioned overlay so the heart button
|
||
can nest without invalid <button>-in-<a> markup. Shell nav grows a
|
||
'Liked' entry pointing at /library/liked."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `/library/liked` page
|
||
|
||
**Files:**
|
||
- Create: `web/src/routes/library/liked/+page.svelte`
|
||
- Create: `web/src/routes/library/liked/liked.test.ts`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `web/src/routes/library/liked/liked.test.ts`:
|
||
|
||
```ts
|
||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||
import { mockInfiniteQuery } from '../../../test-utils/query';
|
||
import type { ArtistRef, AlbumRef, TrackRef, Page } from '$lib/api/types';
|
||
import { readable } from 'svelte/store';
|
||
|
||
vi.mock('$lib/api/likes', () => ({
|
||
createLikedIdsQuery: () => readable({
|
||
data: { track_ids: [], album_ids: [], artist_ids: [] },
|
||
isPending: false,
|
||
isError: false
|
||
}),
|
||
createLikedTracksInfiniteQuery: vi.fn(),
|
||
createLikedAlbumsInfiniteQuery: vi.fn(),
|
||
createLikedArtistsInfiniteQuery: vi.fn(),
|
||
likeEntity: vi.fn(),
|
||
unlikeEntity: vi.fn()
|
||
}));
|
||
|
||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||
const actual = (await orig()) as Record<string, unknown>;
|
||
return { ...actual, useQueryClient: () => ({}) };
|
||
});
|
||
|
||
vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } }));
|
||
vi.mock('$lib/player/store.svelte', () => ({
|
||
playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn()
|
||
}));
|
||
|
||
import LikedPage from './+page.svelte';
|
||
import {
|
||
createLikedTracksInfiniteQuery,
|
||
createLikedAlbumsInfiniteQuery,
|
||
createLikedArtistsInfiniteQuery
|
||
} from '$lib/api/likes';
|
||
|
||
function page<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
|
||
return { items, total, offset, limit };
|
||
}
|
||
|
||
afterEach(() => vi.clearAllMocks());
|
||
|
||
describe('liked library page', () => {
|
||
test('renders three sections when each has items', () => {
|
||
const ar: ArtistRef = { id: 'a1', name: 'X', album_count: 1 };
|
||
const al: AlbumRef = {
|
||
id: 'al1', title: 'Y', artist_id: 'a1', artist_name: 'X',
|
||
year: 2020, track_count: 1, duration_sec: 100, cover_url: '/x'
|
||
};
|
||
const tr: TrackRef = {
|
||
id: 't1', title: 'Z', album_id: 'al1', album_title: 'Y',
|
||
artist_id: 'a1', artist_name: 'X',
|
||
track_number: 1, disc_number: 1, duration_sec: 100,
|
||
stream_url: '/api/tracks/t1/stream'
|
||
};
|
||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockInfiniteQuery({ pages: [page([ar], 1)] })
|
||
);
|
||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockInfiniteQuery({ pages: [page([al], 1)] })
|
||
);
|
||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockInfiniteQuery({ pages: [page([tr], 1)] })
|
||
);
|
||
render(LikedPage);
|
||
expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument();
|
||
expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument();
|
||
expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument();
|
||
});
|
||
|
||
test('empty section renders "No liked X yet" hint', () => {
|
||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
||
);
|
||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
||
);
|
||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockInfiniteQuery({ pages: [page<TrackRef>([], 0)] })
|
||
);
|
||
render(LikedPage);
|
||
expect(screen.getAllByText(/no liked/i)).toHaveLength(3);
|
||
});
|
||
|
||
test('Load more calls fetchNextPage on each section that has more', async () => {
|
||
const fetchTracks = vi.fn();
|
||
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockInfiniteQuery({ pages: [page<TrackRef>([], 100)], hasNextPage: true, fetchNextPage: fetchTracks })
|
||
);
|
||
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
|
||
);
|
||
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] })
|
||
);
|
||
render(LikedPage);
|
||
const buttons = screen.getAllByRole('button', { name: /load more/i });
|
||
expect(buttons).toHaveLength(1);
|
||
await fireEvent.click(buttons[0]);
|
||
expect(fetchTracks).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm fail**
|
||
|
||
Run: `cd web && npm test -- 'src/routes/library/liked/liked.test.ts'`
|
||
Expected: FAIL — page module not found.
|
||
|
||
- [ ] **Step 3: Implement `web/src/routes/library/liked/+page.svelte`**
|
||
|
||
```svelte
|
||
<script lang="ts">
|
||
import {
|
||
createLikedTracksInfiniteQuery,
|
||
createLikedAlbumsInfiniteQuery,
|
||
createLikedArtistsInfiniteQuery
|
||
} from '$lib/api/likes';
|
||
import ArtistRow from '$lib/components/ArtistRow.svelte';
|
||
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
||
import TrackRow from '$lib/components/TrackRow.svelte';
|
||
|
||
const artistsStore = $derived(createLikedArtistsInfiniteQuery());
|
||
const albumsStore = $derived(createLikedAlbumsInfiniteQuery());
|
||
const tracksStore = $derived(createLikedTracksInfiniteQuery());
|
||
|
||
const artistsQuery = $derived($artistsStore);
|
||
const albumsQuery = $derived($albumsStore);
|
||
const tracksQuery = $derived($tracksStore);
|
||
|
||
const artists = $derived(artistsQuery?.data?.pages?.flatMap((p) => p.items) ?? []);
|
||
const albums = $derived(albumsQuery?.data?.pages?.flatMap((p) => p.items) ?? []);
|
||
const tracks = $derived(tracksQuery?.data?.pages?.flatMap((p) => p.items) ?? []);
|
||
|
||
const artistsTotal = $derived(artistsQuery?.data?.pages?.[0]?.total ?? 0);
|
||
const albumsTotal = $derived(albumsQuery?.data?.pages?.[0]?.total ?? 0);
|
||
const tracksTotal = $derived(tracksQuery?.data?.pages?.[0]?.total ?? 0);
|
||
</script>
|
||
|
||
<div class="space-y-6">
|
||
<h1 class="text-2xl font-semibold">Liked</h1>
|
||
|
||
<section>
|
||
<h2 class="mb-2 text-lg font-semibold">Artists</h2>
|
||
{#if artistsTotal === 0}
|
||
<p class="text-text-secondary">No liked artists yet.</p>
|
||
{:else}
|
||
<div>
|
||
{#each artists as a (a.id)}
|
||
<ArtistRow artist={a} />
|
||
{/each}
|
||
</div>
|
||
{#if artistsQuery?.hasNextPage}
|
||
<button
|
||
type="button"
|
||
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
||
disabled={artistsQuery.isFetchingNextPage}
|
||
onclick={() => artistsQuery.fetchNextPage()}
|
||
>
|
||
{artistsQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||
</button>
|
||
{/if}
|
||
{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<h2 class="mb-2 text-lg font-semibold">Albums</h2>
|
||
{#if albumsTotal === 0}
|
||
<p class="text-text-secondary">No liked albums yet.</p>
|
||
{:else}
|
||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||
{#each albums as al (al.id)}
|
||
<AlbumCard album={al} />
|
||
{/each}
|
||
</div>
|
||
{#if albumsQuery?.hasNextPage}
|
||
<button
|
||
type="button"
|
||
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
||
disabled={albumsQuery.isFetchingNextPage}
|
||
onclick={() => albumsQuery.fetchNextPage()}
|
||
>
|
||
{albumsQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||
</button>
|
||
{/if}
|
||
{/if}
|
||
</section>
|
||
|
||
<section>
|
||
<h2 class="mb-2 text-lg font-semibold">Tracks</h2>
|
||
{#if tracksTotal === 0}
|
||
<p class="text-text-secondary">No liked tracks yet.</p>
|
||
{:else}
|
||
<div class="overflow-hidden rounded border border-border">
|
||
{#each tracks as t, i (t.id)}
|
||
<TrackRow tracks={tracks} index={i} />
|
||
{/each}
|
||
</div>
|
||
{#if tracksQuery?.hasNextPage}
|
||
<button
|
||
type="button"
|
||
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
|
||
disabled={tracksQuery.isFetchingNextPage}
|
||
onclick={() => tracksQuery.fetchNextPage()}
|
||
>
|
||
{tracksQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
||
</button>
|
||
{/if}
|
||
{/if}
|
||
</section>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, confirm pass**
|
||
|
||
Run: `cd web && npm test -- 'src/routes/library/liked/liked.test.ts'`
|
||
Expected: PASS — 3 tests.
|
||
|
||
- [ ] **Step 5: Type-check**
|
||
|
||
Run: `cd web && npm run check`
|
||
Expected: `0 ERRORS 0 WARNINGS`.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add web/src/routes/library/liked/+page.svelte web/src/routes/library/liked/liked.test.ts
|
||
git commit -m "feat(web): add /library/liked page with three sections
|
||
|
||
Each section is its own infinite query against /api/likes/{type}.
|
||
Empty sections show 'No liked X yet' (gated on total === 0). Reuses
|
||
ArtistRow / AlbumCard / TrackRow for rendering — same components as
|
||
the search overflow pages."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Final verification + branch finish
|
||
|
||
**Files:** none (verification only).
|
||
|
||
- [ ] **Step 1: Full Go suite (with DB)**
|
||
|
||
Run:
|
||
```bash
|
||
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
||
go test -p 1 ./...
|
||
```
|
||
Expected: all packages PASS.
|
||
|
||
- [ ] **Step 2: Go lint**
|
||
|
||
Run: `golangci-lint run ./...`
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 3: Web check + tests + build**
|
||
|
||
Run: `cd web && npm run check && npm test && npm run build`
|
||
Expected: check 0/0; all vitest files pass; build emits `web/build/`.
|
||
|
||
Run: `git checkout -- web/build/index.html`
|
||
Expected: committed placeholder restored.
|
||
|
||
- [ ] **Step 4: Docker build smoke**
|
||
|
||
Run: `docker build -t minstrel:m2-likes-smoke .`
|
||
Expected: image builds.
|
||
|
||
Run: `docker run --rm --entrypoint /bin/sh minstrel:m2-likes-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'`
|
||
Expected: `ok`.
|
||
|
||
- [ ] **Step 5: Local end-to-end manual check**
|
||
|
||
```bash
|
||
docker compose up --build -d
|
||
```
|
||
|
||
Browser at `localhost:4533`:
|
||
|
||
1. Sign in (use the bootstrap-logged password). Click heart on a track row → fills instantly. Refresh → still filled.
|
||
2. Verify in psql:
|
||
```bash
|
||
docker exec minstrel-postgres-1 psql -U minstrel -d minstrel \
|
||
-c "SELECT user_id, track_id FROM general_likes;"
|
||
```
|
||
3. Click heart on an album card → row in `general_likes_albums`.
|
||
4. Click heart on an artist row → row in `general_likes_artists`.
|
||
5. Click filled heart again → row gone in each case.
|
||
6. Open Feishin/Symfonium pointed at the same instance. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled.
|
||
7. Unstar from Subsonic. Web SPA's heart empties on next refetch.
|
||
8. Visit `/library/liked` via the new "Liked" sidebar link. Three sections render with the items above.
|
||
9. Currently-playing track shows filled heart in `PlayerBar` if liked; toggling from `PlayerBar` updates state everywhere the same track is rendered.
|
||
|
||
Tear down:
|
||
```bash
|
||
docker compose down
|
||
```
|
||
|
||
- [ ] **Step 6: Finish the branch**
|
||
|
||
Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green.
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
**Spec coverage:**
|
||
- Schema (3 tables) → Task 1.
|
||
- sqlc queries → Task 1.
|
||
- Native API endpoints (7) → Task 2.
|
||
- Subsonic star/unstar with validate-all-first atomicity → Task 3.
|
||
- Subsonic getStarred/getStarred2 with cap=500 → Task 4.
|
||
- Web types + qk keys + likes.ts factory + mutations + rollback → Task 5.
|
||
- LikeButton component with stop-propagation → Task 6.
|
||
- Wire heart into TrackRow / AlbumCard / ArtistRow / PlayerBar → Task 7.
|
||
- "Liked" nav entry → Task 7.
|
||
- /library/liked page → Task 8.
|
||
- End-to-end manual + branch finish → Task 9.
|
||
|
||
**Type consistency:**
|
||
- `EntityKind = 'track' | 'album' | 'artist'` — same in TS factory and component prop.
|
||
- `LikedIdsResponse = { track_ids, album_ids, artist_ids }` — server JSON keys match TS keys (snake_case).
|
||
- `qk.likedIds()`, `qk.likedTracks()` etc. — same names everywhere they're consumed.
|
||
- `likeEntity(client, kind, id)` / `unlikeEntity(client, kind, id)` — same signature in factory + component + tests.
|
||
- pgtype.UUID throughout server-side; string at HTTP boundaries via `uuidToString`.
|
||
|
||
**Filename hazards:** all new test files use plain `.test.ts` (no rune syntax in the test bodies — just imports of components that use runes). Route test files use non-`+`-prefixed names (`liked.test.ts`).
|
||
|
||
**Placeholder scan:** no TBD/TODO/later markers. Every code block is complete; every command shows expected output.
|
||
|
||
**ArtistRow refactor risk:** changing `<a>` to `<div>` with positioned overlay link is a surface change. The existing artist tests (in route pages — `src/routes/+page.svelte` etc.) use `screen.getByRole('link')` and `getByText` which both still work. Tests re-run as part of Task 7 Step 7.
|