Files
minstrel/docs/superpowers/plans/2026-04-26-m2-likes.md
T
bvandeusen a3c05aeb34 docs(plan): add M2 likes implementation plan
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.
2026-04-26 15:52:42 -04:00

2496 lines
82 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.