Files
minstrel/docs/superpowers/plans/2026-04-26-m2-likes.md
T
bvandeusen a3c05aeb34 docs(plan): add M2 likes implementation plan
9 tasks: schema + sqlc, native API, Subsonic star/unstar,
Subsonic getStarred/getStarred2, web likes.ts factory + mutations,
LikeButton component, wire LikeButton into existing components,
/library/liked page, final verification.
2026-04-26 15:52:42 -04:00

82 KiB
Raw Blame History

M2 Likes Sub-Plan Implementation

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship liking end-to-end across all three entity types (track / album / artist), wired through both the native /api/* surface and the Subsonic /rest/* compatibility surface, with web UI heart buttons everywhere a track/album/artist is rendered, plus a dedicated /library/liked page. Closes M2.

Architecture: Three new tables (one per entity type) keyed on (user_id, entity_id) with liked_at. Native /api/likes/{tracks,albums,artists}/:id endpoint families (POST/DELETE/GET) plus /api/likes/ids for client-side heart caching. Subsonic /rest/star, /rest/unstar, /rest/getStarred, /rest/getStarred2 route through the same per-table writes with validate-all-first atomicity. Web client uses one LikeButton.svelte reading a single createLikedIdsQuery() cache; mutations do optimistic updates with rollback.

Tech Stack: Go 1.23 + chi + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 (runes) + TypeScript + TanStack Query (Svelte adapter) + Vitest + Testing Library.

Reference: design spec at docs/superpowers/specs/2026-04-26-m2-likes-design.md.


File Structure

New server files:

File Responsibility
internal/db/migrations/0006_likes.up.sql + .down.sql Schema for general_likes, general_likes_albums, general_likes_artists.
internal/db/queries/likes.sql sqlc queries (like/unlike/list/count/list-ids per table). Generated bindings emitted into internal/db/dbq/likes.sql.go.
internal/api/likes.go Native /api/likes/... handlers (3 like, 3 unlike, 3 list, 1 ids).
internal/api/likes_test.go HTTP-level coverage.
internal/subsonic/star.go handleStar, handleUnstar, handleGetStarred, handleGetStarred2.
internal/subsonic/star_test.go Subsonic coverage.

Modified server files:

File Change
internal/api/api.go Register the seven new /api/likes/... routes inside the authed group.
internal/subsonic/subsonic.go Register /star, /unstar, /getStarred, /getStarred2.
internal/subsonic/types.go Add StarredContainer / Starred2Container types.

New web files:

File Responsibility
web/src/lib/api/likes.ts TanStack Query helpers + mutations. Single source for liked-id state.
web/src/lib/api/likes.test.ts Mutation/cache-update unit tests.
web/src/lib/components/LikeButton.svelte Heart icon button. Props entityType: 'track' | 'album' | 'artist', entityId: string.
web/src/lib/components/LikeButton.test.ts Component tests.
web/src/routes/library/liked/+page.svelte Three sections (Artists/Albums/Tracks) with infinite queries.
web/src/routes/library/liked/liked.test.ts Page integration tests.

Modified web files:

File Change
web/src/lib/api/types.ts Add LikedIdsResponse.
web/src/lib/api/queries.ts Add qk.likedIds, qk.likedTracks, qk.likedAlbums, qk.likedArtists.
web/src/lib/components/TrackRow.svelte Insert <LikeButton entityType="track" entityId={track.id} /> next to + queue. Grid grows to [32px_1fr_auto_auto_auto].
web/src/lib/components/AlbumCard.svelte Add <LikeButton entityType="album" entityId={album.id} /> overlay.
web/src/lib/components/ArtistRow.svelte Add <LikeButton entityType="artist" entityId={artist.id} /> before the chevron.
web/src/lib/components/PlayerBar.svelte Add <LikeButton entityType="track" entityId={current.id} /> next to title.
web/src/lib/components/Shell.svelte Add "Liked" entry to navItems pointing at /library/liked.

Task 1: Schema migration + sqlc queries

Files:

  • Create: internal/db/migrations/0006_likes.up.sql

  • Create: internal/db/migrations/0006_likes.down.sql

  • Create: internal/db/queries/likes.sql

  • Generated: internal/db/dbq/likes.sql.go (via sqlc generate)

  • Step 1: Write the up migration

Create internal/db/migrations/0006_likes.up.sql:

-- 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 (add StarredContainer and 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.svelte nav

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:

  1. Sign in (use the bootstrap-logged password). Click heart on a track row → fills instantly. Refresh → still filled.
  2. Verify in psql:
    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:

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.