# 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 `` next to `+ queue`. Grid grows to `[32px_1fr_auto_auto_auto]`. | | `web/src/lib/components/AlbumCard.svelte` | Add `` overlay. | | `web/src/lib/components/ArtistRow.svelte` | Add `` before the chevron. | | `web/src/lib/components/PlayerBar.svelte` | Add `` 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 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).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 = { track: 'tracks', album: 'albums', artist: 'artists' }; const SET_KEY_BY_KIND: Record = { track: 'track_ids', album: 'album_ids', artist: 'artist_ids' }; export function createLikedIdsQuery() { return createQuery({ queryKey: qk.likedIds(), queryFn: () => api.get('/api/likes/ids'), staleTime: 60_000 }); } export function createLikedTracksInfiniteQuery() { return createInfiniteQuery({ queryKey: qk.likedTracks(), queryFn: ({ pageParam = 0 }) => api.get>(`/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>(`/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>(`/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 { const setKey = SET_KEY_BY_KIND[kind]; const previous = client.getQueryData(qk.likedIds()); if (previous) { client.setQueryData(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 { const setKey = SET_KEY_BY_KIND[kind]; const previous = client.getQueryData(qk.likedIds()); if (previous) { client.setQueryData(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; 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 ``` - [ ] **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
{track.track_number ?? '—'} {track.title} {formatDuration(track.duration_sec)}
``` - [ ] **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; return { ...actual, useQueryClient: () => ({}) }; }); ``` Add `import { readable } from 'svelte/store';` at the top. - [ ] **Step 3: Update `AlbumCard.svelte`** Replace: ```svelte ``` Update `AlbumCard.test.ts` with the same likes/query-client mocks as TrackRow.test.ts. - [ ] **Step 4: Update `ArtistRow.svelte`** Replace: ```svelte
{artist.name} {countLabel}
``` Note the structural change: previously ArtistRow was a single ``. Now it's a `
` with the link as a positioned overlay so the LikeButton can sit above it without nesting ` {/if} {/if}

Albums

{#if albumsTotal === 0}

No liked albums yet.

{:else}
{#each albums as al (al.id)} {/each}
{#if albumsQuery?.hasNextPage} {/if} {/if}

Tracks

{#if tracksTotal === 0}

No liked tracks yet.

{:else}
{#each tracks as t, i (t.id)} {/each}
{#if tracksQuery?.hasNextPage} {/if} {/if}
``` - [ ] **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 `` to `
` 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.