Files
minstrel/docs/superpowers/plans/2026-04-27-m3-vectors.md
T
bvandeusen 38beadf22e docs(plan): add M3 session vectors + contextual likes implementation plan
7 tasks: contextual_likes migration + sqlc, events.sql vector queries +
LikeTrack :execrows change, BuildSessionVector pure function, vector
capture in playevents.Writer, contextual_likes helpers + api handler
updates, subsonic handler updates, final verification.
2026-04-27 10:05:52 -04:00

53 KiB

M3 Session Vectors + Contextual Likes Implementation Plan

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: Add the two write paths that produce contextual data for the recommendation engine: (1) compute and persist a session vector at every play_started; (2) snapshot that vector into contextual_likes whenever a user likes a track during an active play_event. Soft-delete contextual_likes on unlike. Backend-only — no UI surface.

Architecture: New migration 0007_contextual_likes creates the missing table (it was referenced in 0005's comments but never CREATE'd). New pure function BuildSessionVector(priorTracks) produces the JSONB shape from spec §6. internal/playevents/writer.go::RecordPlayStarted and RecordSyntheticCompletedPlay extend in-transaction to compute and UPDATE the vector after inserting the play_event. internal/api/likes.go and internal/subsonic/star.go extend their like/unlike paths via two shared helpers in internal/playevents (so both surfaces capture/soft-delete uniformly).

Tech Stack: Go 1.23 + chi + sqlc + pgx/v5. No web changes.

Reference: design spec at docs/superpowers/specs/2026-04-27-m3-vectors-design.md.


File Structure

New server files:

File Responsibility
internal/db/migrations/0007_contextual_likes.up.sql + .down.sql contextual_likes table + partial index (active rows) + GIN index (session_vector).
internal/db/queries/contextual_likes.sql InsertContextualLike, SoftDeleteContextualLikesForUserTrack.
internal/db/dbq/contextual_likes.sql.go Generated bindings.
internal/recommendation/sessionvector.go SessionVector struct + BuildSessionVector(priorTracks []dbq.Track) SessionVector pure function.
internal/recommendation/sessionvector_test.go Pure unit tests for the function (boundary cases).
internal/playevents/contextual_likes.go Two shared helpers: CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger) and SoftDeleteContextualLikes(ctx, q, userID, trackID). Imported by both internal/api and internal/subsonic.
internal/playevents/contextual_likes_test.go Live-DB tests for the helpers.

Modified server files:

File Change
internal/db/queries/events.sql Add ListRecentSessionTracks(sessionID, beforeTime, limit) :many and UpdatePlayEventVector(id, vector) :exec.
internal/db/queries/likes.sql Change LikeTrack from :exec to :execrows (returns int64 affected count).
internal/playevents/writer.go::RecordPlayStarted and RecordSyntheticCompletedPlay After InsertPlayEvent: ListRecentSessionTracks → BuildSessionVector → marshal JSON → UpdatePlayEventVector. Extracted into a shared private helper.
internal/playevents/writer_test.go Three new tests covering vector persistence (seed flag, populated, session-scope isolation).
internal/api/likes.go::handleLikeTrack Capture :execrows result; if 1, call playevents.CaptureContextualLikeIfPlaying.
internal/api/likes.go::handleUnlikeTrack After UnlikeTrack, call playevents.SoftDeleteContextualLikes.
internal/api/likes_test.go Five new tests (capture during open play, no capture without play, no duplicate on idempotent re-like, soft-delete on unlike, history accumulation).
internal/subsonic/star.go::handleStar (track branch) After LikeTrack, capture rows count and call playevents.CaptureContextualLikeIfPlaying.
internal/subsonic/star.go::handleUnstar (track branch) After UnlikeTrack, call playevents.SoftDeleteContextualLikes.
internal/subsonic/star_test.go One new test verifying contextual capture through the Subsonic path.

No web changes.


Task 1: Migration 0007 + contextual_likes sqlc queries

Files:

  • Create: internal/db/migrations/0007_contextual_likes.up.sql

  • Create: internal/db/migrations/0007_contextual_likes.down.sql

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

  • Generated: internal/db/dbq/contextual_likes.sql.go

  • Step 1: Write the up migration

Create internal/db/migrations/0007_contextual_likes.up.sql:

-- contextual_likes captures a per-session-context snapshot at the time of
-- each like. The recommendation engine in M3 sub-plan #3 uses these rows
-- to compute contextual_match_score per (current session, candidate track).
--
-- Multiple rows per (user, track) are allowed and expected — each row
-- represents a like in a specific session context. Soft-deleted rows
-- remain in the table (deleted_at populated) but are filtered out of the
-- engine's queries via the partial index. Re-liking a previously-unliked
-- track adds a NEW row (does not undelete the old one).
--
-- Note: the M2 events migration (0005) commented that contextual_likes
-- "ships nullable now" but the actual CREATE TABLE was missed. This slice
-- ships the table for the first time.

CREATE TABLE contextual_likes (
    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    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(),
    deleted_at      timestamptz,
    session_vector  jsonb,
    session_id      uuid REFERENCES play_sessions(id) ON DELETE SET NULL
);

-- Hot path for both the engine's lookups and the soft-delete UPDATE.
CREATE INDEX contextual_likes_active_idx
    ON contextual_likes (user_id, track_id)
    WHERE deleted_at IS NULL;

-- Vector similarity queries from M3 sub-plan #3 will use this.
CREATE INDEX contextual_likes_vector_idx
    ON contextual_likes USING gin (session_vector);
  • Step 2: Write the down migration

Create internal/db/migrations/0007_contextual_likes.down.sql:

DROP TABLE IF EXISTS contextual_likes;
  • Step 3: Write sqlc queries

Create internal/db/queries/contextual_likes.sql:

-- name: InsertContextualLike :exec
INSERT INTO contextual_likes (user_id, track_id, session_vector, session_id)
VALUES ($1, $2, $3, $4);

-- name: SoftDeleteContextualLikesForUserTrack :exec
-- Marks all currently-active rows for (user, track) as deleted. Idempotent —
-- already-deleted rows aren't re-touched.
UPDATE contextual_likes
SET deleted_at = now()
WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL;
  • Step 4: Generate sqlc bindings

Run: $(go env GOPATH)/bin/sqlc generate Expected: writes internal/db/dbq/contextual_likes.sql.go with InsertContextualLikeParams { UserID pgtype.UUID; TrackID pgtype.UUID; SessionVector []byte; SessionID pgtype.UUID } and SoftDeleteContextualLikesForUserTrackParams { UserID pgtype.UUID; TrackID pgtype.UUID }. Also adds a ContextualLike model type to models.go.

  • Step 5: Verify build

Run: go build ./... Expected: clean.

  • Step 6: Migration smoke

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.

  • Step 7: Commit
git add internal/db/migrations/0007_contextual_likes.up.sql \
        internal/db/migrations/0007_contextual_likes.down.sql \
        internal/db/queries/contextual_likes.sql \
        internal/db/dbq/contextual_likes.sql.go \
        internal/db/dbq/models.go
git commit -m "feat(db): add contextual_likes table (M3 session-context capture)

Table was referenced in migration 0005's comment but never created —
this slice fills the gap. Soft-delete via deleted_at column; hot-path
partial index on active rows; GIN index on session_vector for
M3 sub-plan #3's similarity queries."

Task 2: Vector capture queries in events.sql

Files:

  • Modify: internal/db/queries/events.sql (append two new queries)
  • Generated: internal/db/dbq/events.sql.go (regenerated)
  • Modify: internal/db/queries/likes.sql (LikeTrack becomes :execrows)

The vector capture in RecordPlayStarted needs (a) a query that lists the prior tracks in a session and (b) a query that updates play_events.session_vector_at_play. Bundle the LikeTrack :execrows change here too — same generated-bindings cycle.

  • Step 1: Append ListRecentSessionTracks and UpdatePlayEventVector to internal/db/queries/events.sql
-- name: ListRecentSessionTracks :many
-- Returns up to $3 tracks in session $1 whose play_event started before
-- $2, ordered newest-first. Used by playevents.RecordPlayStarted to
-- build the session_vector for the just-inserted play_event.
SELECT t.* FROM tracks t
JOIN play_events pe ON pe.track_id = t.id
WHERE pe.session_id = $1
  AND pe.started_at < $2
ORDER BY pe.started_at DESC
LIMIT $3;

-- name: UpdatePlayEventVector :exec
-- Used right after InsertPlayEvent to populate session_vector_at_play
-- once the vector has been computed.
UPDATE play_events
SET session_vector_at_play = $2
WHERE id = $1;
  • Step 2: Change LikeTrack to :execrows in internal/db/queries/likes.sql

Find the existing -- name: LikeTrack :exec block. Replace the annotation:

-- name: LikeTrack :execrows
INSERT INTO general_likes (user_id, track_id)
VALUES ($1, $2)
ON CONFLICT (user_id, track_id) DO NOTHING;

(The SQL body is unchanged; only the :exec:execrows annotation changes.)

  • Step 3: Generate sqlc bindings

Run: $(go env GOPATH)/bin/sqlc generate Expected:

  • internal/db/dbq/events.sql.go gains ListRecentSessionTracks and UpdatePlayEventVector functions, plus their param structs.

  • internal/db/dbq/likes.sql.goLikeTrack signature changes from (ctx, params) error to (ctx, params) (int64, error). Build will break at every existing call site.

  • Step 4: Update existing LikeTrack callers to ignore the count

Two sites need updating right now to keep the build green; the next tasks will use the count meaningfully.

In internal/api/likes.go:

// Before:
if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
// After:
if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {

In internal/subsonic/star.go:

// Before:
if err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil {
// After:
if _, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID}); err != nil {
  • Step 5: Verify build

Run: go build ./... Expected: clean.

  • Step 6: Run existing test suite

Run:

MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
  go test -p 1 ./...

Expected: all packages PASS. Existing like tests still work because the behavior is unchanged at this stage; only the function signature evolved.

  • Step 7: Commit
git add internal/db/queries/events.sql internal/db/queries/likes.sql \
        internal/db/dbq/events.sql.go internal/db/dbq/likes.sql.go \
        internal/api/likes.go internal/subsonic/star.go
git commit -m "feat(db): add session-vector capture queries; LikeTrack returns row count

ListRecentSessionTracks + UpdatePlayEventVector for the vector capture
path inside RecordPlayStarted. LikeTrack switches to :execrows so the
contextual-likes capture can detect insert vs already-exists. Existing
callers updated to ignore the count for now; later tasks consume it."

Task 3: Pure BuildSessionVector function

Files:

  • Create: internal/recommendation/sessionvector.go
  • Create: internal/recommendation/sessionvector_test.go

Pure function, no DB. Produces the JSONB-serializable struct from a slice of dbq.Track.

  • Step 1: Write the failing tests

Create internal/recommendation/sessionvector_test.go:

package recommendation

import (
	"encoding/json"
	"testing"

	"github.com/jackc/pgx/v5/pgtype"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

func track(artistID pgtype.UUID, genre string) dbq.Track {
	t := dbq.Track{
		ArtistID: artistID,
	}
	if genre != "" {
		g := genre
		t.Genre = &g
	}
	return t
}

func uuid(s string) pgtype.UUID {
	var u pgtype.UUID
	_ = u.Scan(s)
	return u
}

func TestBuildSessionVector_Empty_IsSeed(t *testing.T) {
	v := BuildSessionVector(nil)
	if !v.Seed {
		t.Errorf("Seed = false, want true for empty input")
	}
	if len(v.Artists) != 0 || len(v.Tags) != 0 || len(v.RecentTrackIDs) != 0 {
		t.Errorf("non-empty collections: %+v", v)
	}
}

func TestBuildSessionVector_OneTrack_IsSeed(t *testing.T) {
	a1 := uuid("11111111-1111-1111-1111-111111111111")
	tr := track(a1, "rock")
	tr.ID = uuid("22222222-2222-2222-2222-222222222222")
	v := BuildSessionVector([]dbq.Track{tr})
	if !v.Seed {
		t.Errorf("1 track: Seed=false, want true (< 3)")
	}
	if len(v.Artists) != 1 {
		t.Errorf("Artists: %+v", v.Artists)
	}
	if v.Tags["rock"] != 1 {
		t.Errorf("Tags: %+v", v.Tags)
	}
	if len(v.RecentTrackIDs) != 1 {
		t.Errorf("RecentTrackIDs: %+v", v.RecentTrackIDs)
	}
}

func TestBuildSessionVector_TwoTracks_IsSeed(t *testing.T) {
	a1 := uuid("11111111-1111-1111-1111-111111111111")
	v := BuildSessionVector([]dbq.Track{track(a1, "rock"), track(a1, "jazz")})
	if !v.Seed {
		t.Errorf("2 tracks: Seed=false, want true (< 3)")
	}
}

func TestBuildSessionVector_ThreeTracks_NotSeed(t *testing.T) {
	a1 := uuid("11111111-1111-1111-1111-111111111111")
	v := BuildSessionVector([]dbq.Track{
		track(a1, "rock"), track(a1, "rock"), track(a1, "rock"),
	})
	if v.Seed {
		t.Errorf("3 tracks: Seed=true, want false")
	}
	// All same artist → 1 entry.
	if len(v.Artists) != 1 {
		t.Errorf("Artists len = %d, want 1 (dedup)", len(v.Artists))
	}
	// All same genre → count 3.
	if v.Tags["rock"] != 3 {
		t.Errorf("Tags['rock'] = %d, want 3", v.Tags["rock"])
	}
}

func TestBuildSessionVector_DistinctArtistsAndGenres(t *testing.T) {
	a1 := uuid("11111111-1111-1111-1111-111111111111")
	a2 := uuid("22222222-2222-2222-2222-222222222222")
	a3 := uuid("33333333-3333-3333-3333-333333333333")
	v := BuildSessionVector([]dbq.Track{
		track(a1, "rock"),
		track(a2, "jazz"),
		track(a3, "rock"),
		track(a1, "experimental"),
	})
	// 4 tracks → not seed.
	if v.Seed {
		t.Errorf("Seed=true, want false")
	}
	// Distinct artists: 3 (a1 dedup'd).
	if len(v.Artists) != 3 {
		t.Errorf("Artists len = %d, want 3", len(v.Artists))
	}
	// Tag counts.
	if v.Tags["rock"] != 2 || v.Tags["jazz"] != 1 || v.Tags["experimental"] != 1 {
		t.Errorf("Tags = %+v", v.Tags)
	}
	if len(v.RecentTrackIDs) != 4 {
		t.Errorf("RecentTrackIDs len = %d", len(v.RecentTrackIDs))
	}
}

func TestBuildSessionVector_NilGenre_NotIndexed(t *testing.T) {
	a1 := uuid("11111111-1111-1111-1111-111111111111")
	v := BuildSessionVector([]dbq.Track{
		track(a1, ""), // empty genre via the helper (nil pointer)
		track(a1, "rock"),
		track(a1, ""),
	})
	if v.Tags["rock"] != 1 {
		t.Errorf("Tags['rock'] = %d, want 1", v.Tags["rock"])
	}
	if _, exists := v.Tags[""]; exists {
		t.Errorf("Tags has empty-string entry: %+v", v.Tags)
	}
}

func TestBuildSessionVector_JSONRoundTrip(t *testing.T) {
	a1 := uuid("11111111-1111-1111-1111-111111111111")
	v := BuildSessionVector([]dbq.Track{
		track(a1, "rock"), track(a1, "jazz"), track(a1, "rock"),
	})
	data, err := json.Marshal(v)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	var got SessionVector
	if err := json.Unmarshal(data, &got); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if got.Seed != v.Seed {
		t.Errorf("Seed: %v != %v", got.Seed, v.Seed)
	}
	if got.Tags["rock"] != v.Tags["rock"] {
		t.Errorf("Tags: %+v != %+v", got.Tags, v.Tags)
	}
	if len(got.Artists) != len(v.Artists) {
		t.Errorf("Artists: len %d != %d", len(got.Artists), len(v.Artists))
	}
	if len(got.RecentTrackIDs) != len(v.RecentTrackIDs) {
		t.Errorf("RecentTrackIDs: len %d != %d", len(got.RecentTrackIDs), len(v.RecentTrackIDs))
	}
}
  • Step 2: Run, confirm fail

Run: go test ./internal/recommendation -run TestBuildSessionVector -v Expected: FAIL — SessionVector, BuildSessionVector undefined.

  • Step 3: Implement internal/recommendation/sessionvector.go
package recommendation

import (
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// SessionVector is the JSONB-serializable shape of a play_event's
// session_vector_at_play column. Spec §6 "Session vector". Sub-plan #3
// will read these to compute contextual_match_score in the radio scoring.
type SessionVector struct {
	Seed           bool           `json:"seed"`
	Artists        []string       `json:"artists"`
	Tags           map[string]int `json:"tags"`
	RecentTrackIDs []string       `json:"recent_track_ids"`
}

// BuildSessionVector aggregates the prior tracks into a session vector.
// Pure: no DB, no time, no randomness. Caller is responsible for ordering
// (oldest first / newest last) and for limiting to the desired N.
//
// Rules:
//   - Seed = len(priorTracks) < 3.
//   - Artists deduplicated, ordered by first appearance.
//   - Tags is a bag-of-counts over tracks.genre. Empty/nil genres do not contribute.
//   - RecentTrackIDs preserves input order (newest last).
func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
	v := SessionVector{
		Seed:           len(priorTracks) < 3,
		Artists:        []string{},
		Tags:           map[string]int{},
		RecentTrackIDs: []string{},
	}
	seen := map[string]bool{}
	for _, t := range priorTracks {
		artistID := uuidString(t.ArtistID)
		if !seen[artistID] {
			seen[artistID] = true
			v.Artists = append(v.Artists, artistID)
		}
		if t.Genre != nil && *t.Genre != "" {
			v.Tags[*t.Genre]++
		}
		v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID))
	}
	return v
}

// uuidString formats a pgtype.UUID as the canonical hex form.
func uuidString(u pgtype.UUID) string {
	if !u.Valid {
		return ""
	}
	b := u.Bytes
	return formatUUIDBytes(b)
}

func formatUUIDBytes(b [16]byte) string {
	const hexdigits = "0123456789abcdef"
	out := make([]byte, 36)
	pos := 0
	for i, by := range b {
		switch i {
		case 4, 6, 8, 10:
			out[pos] = '-'
			pos++
		}
		out[pos] = hexdigits[by>>4]
		out[pos+1] = hexdigits[by&0x0f]
		pos += 2
	}
	return string(out)
}

The pgtype.UUID import is needed:

import (
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
	"github.com/jackc/pgx/v5/pgtype"
)

(Adjust the imports to actually compile — the formatter import is pgtype. Note: pgtype.UUID already has a String() method on pgx/v5; we could call u.String() directly. The hand-rolled formatter is shown for clarity; using u.String() is preferred — replace the body of uuidString with return u.String().)

Final clean form (pick this):

package recommendation

import (
	"github.com/jackc/pgx/v5/pgtype"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

type SessionVector struct {
	Seed           bool           `json:"seed"`
	Artists        []string       `json:"artists"`
	Tags           map[string]int `json:"tags"`
	RecentTrackIDs []string       `json:"recent_track_ids"`
}

func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
	v := SessionVector{
		Seed:           len(priorTracks) < 3,
		Artists:        []string{},
		Tags:           map[string]int{},
		RecentTrackIDs: []string{},
	}
	seen := map[string]bool{}
	for _, t := range priorTracks {
		artistID := uuidString(t.ArtistID)
		if !seen[artistID] {
			seen[artistID] = true
			v.Artists = append(v.Artists, artistID)
		}
		if t.Genre != nil && *t.Genre != "" {
			v.Tags[*t.Genre]++
		}
		v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID))
	}
	return v
}

func uuidString(u pgtype.UUID) string {
	if !u.Valid {
		return ""
	}
	return u.String()
}
  • Step 4: Run, confirm pass

Run: go test ./internal/recommendation -v Expected: PASS — 16 prior tests + 7 new = 23 total.

  • Step 5: Commit
git add internal/recommendation/sessionvector.go internal/recommendation/sessionvector_test.go
git commit -m "feat(recommendation): add BuildSessionVector pure function

Pure aggregation per spec §6: Seed flag (true when prior < 3),
deduplicated Artists ordered by first appearance, Tags bag-of-counts
from tracks.genre (empty genres skipped), RecentTrackIDs preserving
input order. JSON round-trip verified."

Task 4: Vector capture in playevents.Writer

Files:

  • Modify: internal/playevents/writer.go::RecordPlayStarted
  • Modify: internal/playevents/writer.go::RecordSyntheticCompletedPlay
  • Modify: internal/playevents/writer_test.go

Both record paths share the same vector logic — extract a private helper. Both run inside an existing pgx.BeginFunc transaction, so the new SELECT + UPDATE go in the same atomic operation.

  • Step 1: Write three failing tests

Append to internal/playevents/writer_test.go:

import (
	// existing imports
	"encoding/json"
)

func TestRecordPlayStarted_PersistsSessionVector_Seed(t *testing.T) {
	f := newFixture(t, 200_000)
	now := time.Now().UTC()
	res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", now)
	if err != nil {
		t.Fatalf("RecordPlayStarted: %v", err)
	}
	got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.SessionVectorAtPlay == nil {
		t.Fatalf("session_vector_at_play is NULL")
	}
	var vec map[string]any
	if err := json.Unmarshal(got.SessionVectorAtPlay, &vec); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if seed, _ := vec["seed"].(bool); !seed {
		t.Errorf("seed = %v, want true (first play in session)", vec["seed"])
	}
	if artists, _ := vec["artists"].([]any); len(artists) != 0 {
		t.Errorf("artists not empty: %v", artists)
	}
}

func TestRecordPlayStarted_PersistsSessionVector_Populated(t *testing.T) {
	f := newFixture(t, 200_000)
	// Seed 4 prior plays with the same track (reusing the fixture's single
	// track is fine — vector dedup means we'll see 1 artist regardless).
	t0 := time.Now().UTC().Add(-1 * time.Hour)
	for i := 0; i < 4; i++ {
		at := t0.Add(time.Duration(i) * time.Minute)
		_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
	}
	// 5th play — should see vector with 4 prior tracks.
	at := t0.Add(10 * time.Minute)
	res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
	if err != nil {
		t.Fatalf("RecordPlayStarted: %v", err)
	}
	got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if got.SessionVectorAtPlay == nil {
		t.Fatal("session_vector_at_play is NULL")
	}
	var vec map[string]any
	_ = json.Unmarshal(got.SessionVectorAtPlay, &vec)
	if seed, _ := vec["seed"].(bool); seed {
		t.Errorf("seed = true after 4 prior tracks, want false")
	}
	recentIDs, _ := vec["recent_track_ids"].([]any)
	if len(recentIDs) != 4 {
		t.Errorf("recent_track_ids len = %d, want 4", len(recentIDs))
	}
}

func TestRecordPlayStarted_VectorScopedToSession(t *testing.T) {
	f := newFixture(t, 200_000)
	// Play 1: at t=0.
	t0 := time.Now().UTC().Add(-2 * time.Hour)
	_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t0)
	// Play 2: t = t0 + 31 minutes (exceeds 30-min session timeout).
	t1 := t0.Add(31 * time.Minute)
	res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
	if err != nil {
		t.Fatalf("RecordPlayStarted: %v", err)
	}
	got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if got.SessionVectorAtPlay == nil {
		t.Fatal("vector NULL")
	}
	var vec map[string]any
	_ = json.Unmarshal(got.SessionVectorAtPlay, &vec)
	if seed, _ := vec["seed"].(bool); !seed {
		t.Errorf("seed = false in fresh session, want true")
	}
	recentIDs, _ := vec["recent_track_ids"].([]any)
	if len(recentIDs) != 0 {
		t.Errorf("recent_track_ids has %d entries from prior session, want 0", len(recentIDs))
	}
}
  • Step 2: Run, confirm fail

Run:

MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
  go test ./internal/playevents -run TestRecordPlayStarted_PersistsSessionVector -v

Expected: FAIL — session_vector_at_play is NULL because vector capture is not yet wired.

  • Step 3: Add the private helper + extend RecordPlayStarted

Open internal/playevents/writer.go. Add an import block addition for encoding/json and git.fabledsword.com/bvandeusen/minstrel/internal/recommendation. Add a private method:

// captureSessionVector queries the user's prior plays in the given session
// (before `at`), builds the session vector, and UPDATEs the just-inserted
// play_event with it. Runs inside the caller's transaction (q is the
// transaction-bound *dbq.Queries). Errors here propagate — vector capture
// is best-effort, but DB errors should fail the transaction.
func (w *Writer) captureSessionVector(
	ctx context.Context,
	q *dbq.Queries,
	playEventID, sessionID pgtype.UUID,
	at time.Time,
) error {
	priorTracks, err := q.ListRecentSessionTracks(ctx, dbq.ListRecentSessionTracksParams{
		SessionID: sessionID,
		StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
		Limit:     5,
	})
	if err != nil {
		return err
	}
	vec := recommendation.BuildSessionVector(priorTracks)
	vecJSON, err := json.Marshal(vec)
	if err != nil {
		return err
	}
	return q.UpdatePlayEventVector(ctx, dbq.UpdatePlayEventVectorParams{
		ID:                  playEventID,
		SessionVectorAtPlay: vecJSON,
	})
}

The ListRecentSessionTracksParams field names are sqlc-generated from the SQL. Check the actual generated names — the SQL uses $1, $2, $3 so sqlc may use Column1/2/3 OR may infer better names from the WHERE clauses. If the generated struct uses Column1/Column2/Column3, adjust the field names accordingly:

priorTracks, err := q.ListRecentSessionTracks(ctx, dbq.ListRecentSessionTracksParams{
	SessionID: sessionID,                                         // or Column1
	StartedAt: pgtype.Timestamptz{Time: at, Valid: true},          // or Column2
	Limit:     5,                                                  // or Column3
})

(Run sqlc generate first; inspect the actual struct.)

  • Step 4: Call the helper from RecordPlayStarted

In RecordPlayStarted, after the q.InsertPlayEvent call (around line 80), add:

ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{...})
if err != nil {
	return err
}
out.PlayEventID = ev.ID
out.SessionID = sessionID

// Capture session vector for the just-inserted row.
if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil {
	return err
}
return nil
  • Step 5: Call the helper from RecordSyntheticCompletedPlay

In RecordSyntheticCompletedPlay, find the q.InsertPlayEvent(...) call. Right before the subsequent q.UpdatePlayEventEnded(...), add a captureSessionVector call:

ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{...})
if err != nil {
	return err
}
if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil {
	return err
}
// existing UpdatePlayEventEnded follows...
  • Step 6: Run, confirm pass

Run:

MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
  go test ./internal/playevents -v

Expected: PASS — 8 existing + 3 new = 11 tests.

  • Step 7: Lint

Run: golangci-lint run ./internal/playevents/... Expected: clean.

  • Step 8: Commit
git add internal/playevents/writer.go internal/playevents/writer_test.go
git commit -m "feat(playevents): persist session_vector_at_play on every record path

RecordPlayStarted and RecordSyntheticCompletedPlay both capture the
session vector inside their existing transactions. Single private
helper queries the prior 5 tracks, builds the vector, UPDATEs the
just-inserted play_event. Tests verify the seed flag boundary and
session-scope isolation."

Task 5: Contextual-likes helpers + api handler updates

Files:

  • Create: internal/playevents/contextual_likes.go
  • Create: internal/playevents/contextual_likes_test.go
  • Modify: internal/api/likes.go::handleLikeTrack and handleUnlikeTrack
  • Modify: internal/api/likes_test.go

Two helpers in internal/playevents so both internal/api and internal/subsonic use the same code path. The api handlers wire them in here; the subsonic handlers in Task 6.

  • Step 1: Write the helper tests

Create internal/playevents/contextual_likes_test.go:

package playevents

import (
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgtype"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// fixtureWithSecondTrack extends the existing fixture with a second track
// the test uses as the "track being liked while track 1 plays".
type fixtureWithSecondTrack struct {
	fixture
	track2 pgtype.UUID
}

func newFixtureWithSecondTrack(t *testing.T, durationMs int32) fixtureWithSecondTrack {
	f := newFixture(t, durationMs)
	q := dbq.New(f.pool)
	tr2, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title: "Other", AlbumID: f.q.GetAlbumByID, // placeholder — use the same album
		FilePath: "/tmp/track-2.flac", DurationMs: durationMs,
	})
	_ = err // for sketching; the real implementation should look up an existing album/artist from f
	_ = tr2
	return fixtureWithSecondTrack{fixture: f}
}

func TestCaptureContextualLikeIfPlaying_NoOpenEvent_NoOp(t *testing.T) {
	f := newFixture(t, 200_000)
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	q := dbq.New(f.pool)
	if err := CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger); err != nil {
		t.Fatalf("err: %v", err)
	}
	var count int
	_ = f.pool.QueryRow(context.Background(), "SELECT count(*) FROM contextual_likes WHERE user_id=$1", f.user).Scan(&count)
	if count != 0 {
		t.Errorf("count = %d, want 0 (no open play_event)", count)
	}
}

func TestCaptureContextualLikeIfPlaying_OpenEventWithVector_Inserts(t *testing.T) {
	f := newFixture(t, 200_000)
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	// Start a play (creates an open play_event with a populated vector).
	at := time.Now().UTC()
	_, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
	if err != nil {
		t.Fatalf("RecordPlayStarted: %v", err)
	}
	// Like the same track — captures contextual signal.
	q := dbq.New(f.pool)
	if err := CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger); err != nil {
		t.Fatalf("Capture: %v", err)
	}
	var rawVec []byte
	if err := f.pool.QueryRow(context.Background(),
		"SELECT session_vector FROM contextual_likes WHERE user_id=$1 AND track_id=$2",
		f.user, f.track).Scan(&rawVec); err != nil {
		t.Fatalf("query: %v", err)
	}
	var vec map[string]any
	_ = json.Unmarshal(rawVec, &vec)
	if vec == nil {
		t.Errorf("vector empty: %s", rawVec)
	}
}

func TestSoftDeleteContextualLikes_MarksActiveRowsDeleted(t *testing.T) {
	f := newFixture(t, 200_000)
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	at := time.Now().UTC()
	_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
	q := dbq.New(f.pool)
	_ = CaptureContextualLikeIfPlaying(context.Background(), q, f.user, f.track, logger)
	// Now soft-delete.
	if err := SoftDeleteContextualLikes(context.Background(), q, f.user, f.track); err != nil {
		t.Fatalf("SoftDelete: %v", err)
	}
	var deletedAt pgtype.Timestamptz
	if err := f.pool.QueryRow(context.Background(),
		"SELECT deleted_at FROM contextual_likes WHERE user_id=$1 AND track_id=$2",
		f.user, f.track).Scan(&deletedAt); err != nil {
		t.Fatalf("query: %v", err)
	}
	if !deletedAt.Valid {
		t.Errorf("deleted_at not set after SoftDelete")
	}
}

The fixture-extension code above has a placeholder (fixtureWithSecondTrack is sketched). For these tests we don't actually need a second track — using f.track for both the "playing" and "liked" sides is fine because contextual_likes is keyed on (user, track) and the test is verifying capture, not cross-track behavior. Drop the fixtureWithSecondTrack helper; use f.track directly.

  • Step 2: Run, confirm fail

Run:

MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
  go test ./internal/playevents -run TestCaptureContextualLikeIfPlaying -v

Expected: FAIL — CaptureContextualLikeIfPlaying, SoftDeleteContextualLikes undefined.

  • Step 3: Implement internal/playevents/contextual_likes.go
package playevents

import (
	"context"
	"errors"
	"log/slog"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// CaptureContextualLikeIfPlaying snapshots the user's currently-playing
// session context into a new contextual_likes row. No-op if no open
// play_event exists or its session_vector is NULL. Failures are logged
// but never returned to the caller — contextual capture is best-effort
// and must not break the like response.
func CaptureContextualLikeIfPlaying(
	ctx context.Context,
	q *dbq.Queries,
	userID, trackID pgtype.UUID,
	logger *slog.Logger,
) error {
	event, err := q.GetOpenPlayEventForUser(ctx, userID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil // no play in progress; nothing to capture
		}
		logger.Error("playevents: get open play_event", "err", err)
		return nil // best-effort
	}
	if event.SessionVectorAtPlay == nil {
		return nil // event predates this slice; no vector to snapshot
	}
	if err := q.InsertContextualLike(ctx, dbq.InsertContextualLikeParams{
		UserID:        userID,
		TrackID:       trackID,
		SessionVector: event.SessionVectorAtPlay,
		SessionID:     event.SessionID,
	}); err != nil {
		logger.Error("playevents: insert contextual_like", "err", err)
		return nil
	}
	return nil
}

// SoftDeleteContextualLikes marks all currently-active contextual_likes
// rows for (user, track) as deleted. Idempotent.
func SoftDeleteContextualLikes(
	ctx context.Context,
	q *dbq.Queries,
	userID, trackID pgtype.UUID,
) error {
	return q.SoftDeleteContextualLikesForUserTrack(ctx, dbq.SoftDeleteContextualLikesForUserTrackParams{
		UserID:  userID,
		TrackID: trackID,
	})
}

Note: the InsertContextualLikeParams field names follow sqlc's naming — UserID, TrackID, SessionVector, SessionID. If sqlc generates different names (e.g. positional Column1...), adjust per the actual generated code.

  • Step 4: Run helper tests, confirm pass

Run:

MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
  go test ./internal/playevents -v

Expected: PASS — 11 prior tests + 3 helper tests = 14 total.

  • Step 5: Update internal/api/likes.go::handleLikeTrack
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
	}
	rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id})
	if err != nil {
		h.logger.Error("api: like track insert", "err", err)
		writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
		return
	}
	if rows == 1 {
		_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
	}
	w.WriteHeader(http.StatusNoContent)
}

Add the playevents import: "git.fabledsword.com/bvandeusen/minstrel/internal/playevents".

  • Step 6: Update internal/api/likes.go::handleUnlikeTrack
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
	}
	q := dbq.New(h.pool)
	if err := q.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
	}
	if err := playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, id); err != nil {
		h.logger.Error("api: soft-delete contextual_likes", "err", err)
		// Don't fail the response — soft-delete is best-effort.
	}
	w.WriteHeader(http.StatusNoContent)
}
  • Step 7: Add five new tests to internal/api/likes_test.go

Append:

func TestLikeTrack_DuringOpenPlayEvent_WritesContextualLike(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)
	playing := seedTrack(t, pool, album.ID, artist.ID, "Now Playing", 1, 100_000)
	other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000)

	// Simulate a play_started: insert via the writer.
	w := callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`))
	if w.Code != http.StatusOK {
		t.Fatalf("play_started failed: %d %s", w.Code, w.Body.String())
	}
	// Like the OTHER track. With an open play_event for `playing`,
	// the contextual_likes row should reference `other` and the playing
	// track's session vector.
	if r := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID)); r.Code != http.StatusNoContent {
		t.Fatalf("like: %d", r.Code)
	}
	var count int
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&count)
	if count != 1 {
		t.Errorf("contextual_likes count = %d, want 1", count)
	}
}

func TestLikeTrack_NoOpenPlayEvent_NoContextualLike(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)

	if r := callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(track.ID)); r.Code != http.StatusNoContent {
		t.Fatalf("like: %d", r.Code)
	}
	var count int
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM contextual_likes WHERE user_id=$1", user.ID).Scan(&count)
	if count != 0 {
		t.Errorf("contextual_likes count = %d, want 0 (no open play)", count)
	}
}

func TestLikeTrack_RepeatedLike_NoDuplicate(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)
	playing := seedTrack(t, pool, album.ID, artist.ID, "Playing", 1, 100_000)
	other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000)

	// Open play.
	_ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`))
	// First like — captures.
	_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID))
	// Second like — :execrows=0, no contextual write.
	_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID))

	var count int
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&count)
	if count != 1 {
		t.Errorf("contextual_likes count = %d, want 1 (no duplicate from idempotent re-like)", count)
	}
}

func TestUnlikeTrack_SoftDeletesContextualLikes(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)
	playing := seedTrack(t, pool, album.ID, artist.ID, "P", 1, 100_000)
	other := seedTrack(t, pool, album.ID, artist.ID, "O", 2, 100_000)

	_ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`))
	_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID))
	if r := callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(other.ID)); r.Code != http.StatusNoContent {
		t.Fatalf("unlike: %d", r.Code)
	}
	// general_likes deleted.
	var glCount int
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM general_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&glCount)
	if glCount != 0 {
		t.Errorf("general_likes count = %d, want 0", glCount)
	}
	// contextual_likes row exists but deleted_at is set.
	var deletedAtValid bool
	_ = pool.QueryRow(context.Background(),
		"SELECT deleted_at IS NOT NULL FROM contextual_likes WHERE user_id=$1 AND track_id=$2 LIMIT 1",
		user.ID, other.ID).Scan(&deletedAtValid)
	if !deletedAtValid {
		t.Errorf("deleted_at not set after unlike")
	}
}

func TestLikeUnlikeRelike_HistoryAccumulates(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)
	playing := seedTrack(t, pool, album.ID, artist.ID, "P", 1, 100_000)
	other := seedTrack(t, pool, album.ID, artist.ID, "O", 2, 100_000)

	_ = callEvents(h, user, []byte(`{"type":"play_started","track_id":"`+uuidToString(playing.ID)+`"}`))
	_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID))
	_ = callLike(h, user, http.MethodDelete, "/api/likes/tracks/"+uuidToString(other.ID))
	// Re-like in same session — new row should be inserted.
	_ = callLike(h, user, http.MethodPost, "/api/likes/tracks/"+uuidToString(other.ID))

	var total, active int
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, other.ID).Scan(&total)
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2 AND deleted_at IS NULL",
		user.ID, other.ID).Scan(&active)
	if total != 2 {
		t.Errorf("total = %d, want 2 (history accumulates)", total)
	}
	if active != 1 {
		t.Errorf("active = %d, want 1", active)
	}
}
  • Step 8: Run, confirm pass

Run:

MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
  go test ./internal/api -v

Expected: PASS, including the 5 new tests.

  • Step 9: Lint

Run: golangci-lint run ./... Expected: clean.

  • Step 10: Commit
git add internal/playevents/contextual_likes.go internal/playevents/contextual_likes_test.go \
        internal/api/likes.go internal/api/likes_test.go
git commit -m "feat(api): capture contextual_likes on like during open play_event

Two new helpers in internal/playevents (CaptureContextualLikeIfPlaying,
SoftDeleteContextualLikes) called by both api and subsonic surfaces.
Like inserts a new contextual_likes row when LikeTrack actually
inserted (rows=1) AND there's an open play_event with a vector.
Unlike soft-deletes via deleted_at. Idempotent in both directions.

Subsonic wiring lands in the next commit."

Task 6: Subsonic handler updates + verification test

Files:

  • Modify: internal/subsonic/star.go::handleStar (track branch)

  • Modify: internal/subsonic/star.go::handleUnstar (track branch)

  • Modify: internal/subsonic/star_test.go

  • Step 1: Update handleStar track branch

In internal/subsonic/star.go, find the section that calls q.LikeTrack(...). Replace:

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
	}
}

with:

if trackID.Valid {
	rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID})
	if err != nil {
		WriteFail(w, r, ErrGeneric, "Could not star track")
		return
	}
	if rows == 1 {
		_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger)
	}
}

The mediaHandlers struct currently has fields pool *pgxpool.Pool and events *playevents.Writer. It does NOT have a logger. Two ways to handle:

  • Option A: add a logger *slog.Logger field to mediaHandlers. Update newMediaHandlers(pool, events, logger) and the subsonic.Mount call site (internal/subsonic/subsonic.go) to pass it through.
  • Option B: pass a no-op logger inline: slog.New(slog.NewTextHandler(io.Discard, nil)).

Use Option A (cleanest). Add the field; thread the logger through Mount. The subsonic.Mount signature already takes a *slog.Logger per existing code.

Add the import: "git.fabledsword.com/bvandeusen/minstrel/internal/playevents".

  • Step 2: Update handleUnstar track branch
if t, ok := parseUUID(params.Get("id")); ok {
	_ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t})
	_ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t)
}
  • Step 3: Update mediaHandlers struct + factory + caller

Add logger *slog.Logger field to mediaHandlers. Update newMediaHandlers(pool, events, logger) constructor. Update internal/subsonic/subsonic.go::Mount's call site:

m := newMediaHandlers(pool, events, logger)

(logger is already a parameter to Mount.)

  • Step 4: Add verification test in star_test.go

Append:

func TestHandleStar_DuringNowPlaying_WritesContextualLike(t *testing.T) {
	pool, user, track1, _, _ := testStarPool(t)
	// Add a second track so star refers to a different track than the one playing.
	q := dbq.New(pool)
	a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "Y", SortName: "Y"})
	al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Y", SortTitle: "Y", ArtistID: a.ID})
	track2, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title: "Star Me", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/t2.flac", DurationMs: 100_000,
	})

	m := newStarHandlers(pool)
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	m.logger = logger

	// Start now-playing for track1 (creates open play_event with vector).
	q1 := url.Values{}
	q1.Set("id", uuidToID(track1.ID))
	q1.Set("submission", "false")
	scrobReq := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q1.Encode(), nil)
	scrobCtx := context.WithValue(scrobReq.Context(), userCtxKey, user)
	scrobResp := httptest.NewRecorder()
	m.handleScrobble(scrobResp, scrobReq.WithContext(scrobCtx))
	if scrobResp.Code != http.StatusOK {
		t.Fatalf("scrobble: %d", scrobResp.Code)
	}

	// Star track2.
	q2 := url.Values{}
	q2.Set("id", uuidToID(track2.ID))
	starReq := httptest.NewRequest(http.MethodGet, "/rest/star?"+q2.Encode(), nil)
	starCtx := context.WithValue(starReq.Context(), userCtxKey, user)
	starResp := httptest.NewRecorder()
	m.handleStar(starResp, starReq.WithContext(starCtx))
	if starResp.Code != http.StatusOK {
		t.Fatalf("star: %d", starResp.Code)
	}

	// Verify contextual_likes row.
	var count int
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, track2.ID).Scan(&count)
	if count != 1 {
		t.Errorf("contextual_likes count = %d, want 1", count)
	}
}

The newStarHandlers helper currently doesn't set a logger; the mediaHandlers factory needs to be updated so the test passes the logger. Adjust the test fixture to construct mediaHandlers with a real (or io.Discard) logger.

  • Step 5: Run, 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 — existing scrobble + star tests + 1 new test.

  • 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
git commit -m "feat(subsonic): wire contextual_likes capture/soft-delete into star/unstar

handleStar's track branch calls playevents.CaptureContextualLikeIfPlaying
when the underlying LikeTrack actually inserted a row. handleUnstar
calls SoftDeleteContextualLikes after every track-id unstar. Same
helpers as the api surface — single source of truth.

mediaHandlers struct gains a logger field threaded through Mount."

Task 7: 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: Coverage on recommendation + playevents

Run:

MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
  go test -coverprofile=/tmp/m3v.cover ./internal/recommendation/... ./internal/playevents/...
go tool cover -func=/tmp/m3v.cover | tail -3

Expected: total ≥ 70% per the M3 milestone description (sub-plan #1 already pushed recommendation to 95.5%; this slice keeps it high while bringing in playevents improvements).

  • Step 3: Go lint

Run: golangci-lint run ./... Expected: clean.

  • Step 4: Web check + tests + build

Run: cd web && npm run check && npm test && npm run build Expected: check 0/0; tests pass; build emits web/build/. (No web changes in this slice; this is a regression check.)

Run: git checkout -- web/build/index.html Expected: committed placeholder restored.

  • Step 5: Docker build smoke

Run: docker build -t minstrel:m3-vectors-smoke . Expected: image builds.

Run: docker run --rm --entrypoint /bin/sh minstrel:m3-vectors-smoke -c 'test -x /usr/local/bin/minstrel && echo ok' Expected: ok.

  • Step 6: Local end-to-end manual check
docker compose up --build -d

Browser at localhost:4533 (sign in with the dev admin creds):

  1. Play 4 tracks all the way through.
  2. psql ... SELECT id, session_vector_at_play FROM play_events ORDER BY started_at DESC LIMIT 5 — vectors populated, first 3 have seed: true, 4th onward seed: false.
  3. While the 4th track is playing, like it via the heart button. SELECT * FROM contextual_likes shows a row with that track's session_vector.
  4. Like a different track NOT currently playing. general_likes row appears; no contextual_likes row.
  5. Unlike the first track. general_likes deleted; contextual_likes row still exists but deleted_at is set.
  6. Re-like that track in a new session. New contextual_likes row with deleted_at IS NULL and a different session_vector.
  7. From Feishin: send a scrobble with submission=false for track A, then star track B. Verify contextual_likes captured the Subsonic-driven event.

Tear down:

docker compose down
  • Step 7: 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:

  • New contextual_likes table + indexes → Task 1.
  • Pure session-vector function → Task 3.
  • Vector capture inside RecordPlayStarted + RecordSyntheticCompletedPlay → Task 4.
  • LikeTrack :execrows change → Task 2.
  • Contextual capture on like (api) → Task 5.
  • Soft-delete on unlike (api) → Task 5.
  • Same on subsonic → Task 6.
  • ListRecentSessionTracks + UpdatePlayEventVector queries → Task 2.
  • Cross-session isolation (vector scoped to current session) → Task 4 third test.
  • Edge cases (no-op without play, no duplicate on idempotent re-like, history on relike) → Task 5 tests.
  • Subsonic verification → Task 6 test.
  • ≥70% coverage maintained → Task 7 step 2.

Type consistency:

  • SessionVector { Seed, Artists, Tags, RecentTrackIDs } — same struct used in BuildSessionVector (Task 3) + serialized as JSON in play_events.session_vector_at_play (Task 4) + read back from contextual_likes.session_vector (Task 5).
  • CaptureContextualLikeIfPlaying(ctx, q, userID, trackID, logger) — same signature in helpers (Task 5) + api callers (Task 5) + subsonic callers (Task 6).
  • SoftDeleteContextualLikes(ctx, q, userID, trackID) — same signature in helpers + both call sites.
  • pgtype.UUID server-side throughout; JSON serialization via u.String().

Filename hazards: none. Only Go files; no SvelteKit route walker concerns.

Placeholder scan: no TBD/TODO/later markers. Each step has complete code; the sqlc-generated-name caveats (Column1/Column2 vs UserID/TrackID) are documented inline so engineers know to verify after sqlc generate.

Performance note: the new SELECT + UPDATE inside RecordPlayStarted adds ~1-2 ms per play event. Negligible at v1 scale. The contextual_likes writes happen at human click rates — also negligible.