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.
82 KiB
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(viasqlc generate) -
Step 1: Write the up migration
Create internal/db/migrations/0006_likes.up.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:
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:
-- 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:
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
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:
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:
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
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):
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:
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:
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
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:
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:
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
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:
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:
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
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(addStarredContainerand response types) -
Step 1: Add tests for getStarred2
Append to internal/subsonic/star_test.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:
// 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:
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:
register(sub, "/getStarred", m.handleGetStarred)
register(sub, "/getStarred2", m.handleGetStarred2)
- Step 5: Run tests, confirm pass
Run:
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:
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
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:
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:
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:
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
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
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:
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
<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
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:
<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):
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:
<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:
<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:
<!-- 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.sveltenav
In the navItems array:
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
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:
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
<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
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:
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
docker compose up --build -d
Browser at localhost:4533:
- Sign in (use the bootstrap-logged password). Click heart on a track row → fills instantly. Refresh → still filled.
- Verify in psql:
docker exec minstrel-postgres-1 psql -U minstrel -d minstrel \ -c "SELECT user_id, track_id FROM general_likes;" - Click heart on an album card → row in
general_likes_albums. - Click heart on an artist row → row in
general_likes_artists. - Click filled heart again → row gone in each case.
- Open Feishin/Symfonium pointed at the same instance. Star a track. Refresh web SPA (or focus tab). Heart on that track is filled.
- Unstar from Subsonic. Web SPA's heart empties on next refetch.
- Visit
/library/likedvia the new "Liked" sidebar link. Three sections render with the items above. - Currently-playing track shows filled heart in
PlayerBarif liked; toggling fromPlayerBarupdates state everywhere the same track is rendered.
Tear down:
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.