7df48ee9bc
9 tasks: schema migration, events config, playsessions service, playevents writer, /api/events handler, Subsonic scrobble integration, web clientId + events dispatcher, layout wiring, final verification. Tightly TDD-shaped with full code in every step.
2391 lines
76 KiB
Markdown
2391 lines
76 KiB
Markdown
# M2 Events 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 the play-event ingestion pipeline end-to-end (schema, session service, `POST /api/events` handler, Subsonic `/rest/scrobble` integration, SvelteKit player wiring) so M3's recommendation engine has play history data to consume.
|
|
|
|
**Architecture:** New tables `play_sessions`, `play_events`, `skip_events` per spec §5. Two new Go packages: `internal/playsessions` (FindOrCreate with 30-min rolling rule) and `internal/playevents` (RecordPlayStarted/Ended/Skipped/SyntheticCompletedPlay, owning the auto-close-prior + skip-classification logic). One HTTP handler at `POST /api/events` and one Subsonic handler revision. Web client gets a `useEventsDispatcher` Svelte module that watches the player store and posts events; `pagehide` uses `navigator.sendBeacon`.
|
|
|
|
**Tech Stack:** Go 1.23 + chi + sqlc + pgx/v5. SvelteKit 2 + Svelte 5 (runes) + TypeScript + Vitest + Testing Library.
|
|
|
|
**Reference:** design spec at `docs/superpowers/specs/2026-04-25-m2-events-design.md`.
|
|
|
|
**Naming note:** the table is `play_sessions` (not `sessions` — `sessions` already holds HTTP auth tokens from migration 0004). The Go package is `internal/playsessions`.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**New server files:**
|
|
|
|
| File | Responsibility |
|
|
|---|---|
|
|
| `internal/db/migrations/0005_events.up.sql` + `.down.sql` | Schema for `play_sessions`, `play_events`, `skip_events`. |
|
|
| `internal/db/queries/events.sql` | sqlc queries for the new tables. Generated bindings emitted into `internal/db/dbq/events.sql.go`. |
|
|
| `internal/playsessions/service.go` | `FindOrCreate(ctx, q, userID, eventTime, clientID, timeout) (sessionID, error)`. Composable into a transaction. |
|
|
| `internal/playsessions/service_test.go` | Live-DB tests for the four scenarios (no prior, within window, beyond window, race). |
|
|
| `internal/playevents/writer.go` | `RecordPlayStarted`, `RecordPlayEnded`, `RecordPlaySkipped`, `RecordSyntheticCompletedPlay`. Owns auto-close-prior, skip classification rule, skip_events writes. |
|
|
| `internal/playevents/writer_test.go` | Direct tests covering rule edges, auto-close, synthetic write. |
|
|
| `internal/api/events.go` | `handleEvents` HTTP handler dispatching by `type`. |
|
|
| `internal/api/events_test.go` | HTTP-level coverage for happy paths, validation errors, ownership-mismatch. |
|
|
| `internal/subsonic/scrobble_test.go` | New scrobble tests (existing file has none). |
|
|
|
|
**Modified server files:**
|
|
|
|
| File | Change |
|
|
|---|---|
|
|
| `internal/api/api.go` | Register `authed.Post("/events", h.handleEvents)`. |
|
|
| `internal/subsonic/stream.go` | `handleScrobble` calls `playevents.Writer` instead of `nowPlayingMap`. Delete `nowPlayingMap` struct + factory + the `nowPlaying` field. Constructor signature simplifies. |
|
|
| `internal/config/config.go` + `config.example.yaml` | Add `EventsConfig` struct: `SessionTimeoutMinutes` (default 30), `SkipMaxCompletionRatio` (default 0.5), `SkipMaxDurationPlayedMs` (default 30000). Wire into `Default()` and YAML loader. |
|
|
|
|
**New web files:**
|
|
|
|
| File | Responsibility |
|
|
|---|---|
|
|
| `web/src/lib/player/clientId.ts` | `getOrCreateClientId(): string` — sessionStorage-backed UUID. |
|
|
| `web/src/lib/player/clientId.test.ts` | Cover create + reuse. |
|
|
| `web/src/lib/player/events.svelte.ts` | `useEventsDispatcher()` — `$effect`-based watcher on player state; owns `_playEventId`; calls `api.post`; installs `pagehide` listener. |
|
|
| `web/src/lib/player/events.svelte.test.ts` | State-machine tests with mocked `api.post` and spied `navigator.sendBeacon`. |
|
|
|
|
**Modified web files:**
|
|
|
|
| File | Change |
|
|
|---|---|
|
|
| `web/src/lib/api/types.ts` | Add `EventRequest`, `EventResponse` types. |
|
|
| `web/src/routes/+layout.svelte` | Call `useEventsDispatcher()` once at mount, alongside the existing `useMediaSession()`. |
|
|
|
|
---
|
|
|
|
## Task 1: Schema migration + sqlc queries
|
|
|
|
**Files:**
|
|
- Create: `internal/db/migrations/0005_events.up.sql`
|
|
- Create: `internal/db/migrations/0005_events.down.sql`
|
|
- Create: `internal/db/queries/events.sql`
|
|
- Generated: `internal/db/dbq/events.sql.go` (via `sqlc generate`)
|
|
|
|
This task ships the bare schema + queries. Tests ride along with later tasks; the verification here is `go build` succeeds after `sqlc generate` and a manual migration smoke check.
|
|
|
|
- [ ] **Step 1: Write the up migration**
|
|
|
|
Create `internal/db/migrations/0005_events.up.sql`:
|
|
|
|
```sql
|
|
-- M2 listening telemetry: play_sessions, play_events, skip_events.
|
|
-- Note the table is named play_sessions (not sessions — that table already
|
|
-- exists from migration 0004 and holds HTTP auth sessions). Listening
|
|
-- sessions are an entirely separate concept driven by spec §6.
|
|
--
|
|
-- session_vector_at_play and contextual_likes ship as nullable now even
|
|
-- though M2 writes NULL; M3 will populate them without a follow-up
|
|
-- migration.
|
|
|
|
CREATE TABLE play_sessions (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
started_at timestamptz NOT NULL,
|
|
ended_at timestamptz,
|
|
last_event_at timestamptz NOT NULL,
|
|
track_count integer NOT NULL DEFAULT 0,
|
|
client_id text
|
|
);
|
|
CREATE INDEX play_sessions_user_last_event_idx ON play_sessions (user_id, last_event_at DESC);
|
|
|
|
CREATE TABLE play_events (
|
|
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,
|
|
session_id uuid NOT NULL REFERENCES play_sessions(id) ON DELETE CASCADE,
|
|
started_at timestamptz NOT NULL,
|
|
ended_at timestamptz,
|
|
duration_played_ms integer,
|
|
completion_ratio double precision,
|
|
was_skipped boolean NOT NULL DEFAULT false,
|
|
client_id text,
|
|
session_vector_at_play jsonb,
|
|
scrobbled_at timestamptz
|
|
);
|
|
CREATE INDEX play_events_user_started_idx ON play_events (user_id, started_at DESC);
|
|
CREATE INDEX play_events_user_track_idx ON play_events (user_id, track_id);
|
|
|
|
CREATE TABLE skip_events (
|
|
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,
|
|
session_id uuid NOT NULL REFERENCES play_sessions(id) ON DELETE CASCADE,
|
|
skipped_at timestamptz NOT NULL,
|
|
position_ms integer NOT NULL
|
|
);
|
|
CREATE INDEX skip_events_user_skipped_idx ON skip_events (user_id, skipped_at DESC);
|
|
```
|
|
|
|
- [ ] **Step 2: Write the down migration**
|
|
|
|
Create `internal/db/migrations/0005_events.down.sql`:
|
|
|
|
```sql
|
|
DROP TABLE IF EXISTS skip_events;
|
|
DROP TABLE IF EXISTS play_events;
|
|
DROP TABLE IF EXISTS play_sessions;
|
|
```
|
|
|
|
- [ ] **Step 3: Write the sqlc queries**
|
|
|
|
Create `internal/db/queries/events.sql`:
|
|
|
|
```sql
|
|
-- name: GetMostRecentPlaySessionForUser :one
|
|
SELECT * FROM play_sessions
|
|
WHERE user_id = $1
|
|
ORDER BY last_event_at DESC
|
|
LIMIT 1;
|
|
|
|
-- name: InsertPlaySession :one
|
|
INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
|
VALUES ($1, $2, $2, $3)
|
|
RETURNING *;
|
|
|
|
-- name: TouchPlaySessionLastEvent :exec
|
|
UPDATE play_sessions
|
|
SET last_event_at = $2,
|
|
track_count = track_count + 1
|
|
WHERE id = $1;
|
|
|
|
-- name: GetOpenPlayEventForUser :one
|
|
-- Returns the most recent play_event for a user where ended_at IS NULL.
|
|
-- Used by the auto-close-prior step in playevents.RecordPlayStarted.
|
|
SELECT * FROM play_events
|
|
WHERE user_id = $1 AND ended_at IS NULL
|
|
ORDER BY started_at DESC
|
|
LIMIT 1;
|
|
|
|
-- name: InsertPlayEvent :one
|
|
INSERT INTO play_events (
|
|
user_id, track_id, session_id, started_at, client_id
|
|
) VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *;
|
|
|
|
-- name: UpdatePlayEventEnded :one
|
|
-- Closes a play_event by id with the given ended_at, duration, and skip flag.
|
|
-- completion_ratio is computed from duration_played_ms and the track duration
|
|
-- (looked up by the caller — sqlc doesn't do joins on UPDATE).
|
|
UPDATE play_events
|
|
SET ended_at = $2,
|
|
duration_played_ms = $3,
|
|
completion_ratio = $4,
|
|
was_skipped = $5
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetPlayEventByID :one
|
|
SELECT * FROM play_events WHERE id = $1;
|
|
|
|
-- name: InsertSkipEvent :one
|
|
INSERT INTO skip_events (user_id, track_id, session_id, skipped_at, position_ms)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *;
|
|
```
|
|
|
|
- [ ] **Step 4: Generate sqlc bindings**
|
|
|
|
Run: `sqlc generate`
|
|
Expected: writes `internal/db/dbq/events.sql.go` with `PlaySession`, `PlayEvent`, `SkipEvent` model types + the named query functions.
|
|
|
|
- [ ] **Step 5: Verify `go build` works**
|
|
|
|
Run: `go build ./...`
|
|
Expected: builds clean.
|
|
|
|
- [ ] **Step 6: Manual migration smoke**
|
|
|
|
Bring up postgres if needed: `docker compose up -d postgres`
|
|
|
|
Run:
|
|
```bash
|
|
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
|
go test ./internal/db -run TestMigrate -v
|
|
```
|
|
Expected: PASS. (Existing migration test covers the up/down round trip generically; it will exercise 0005 automatically.)
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add internal/db/migrations/0005_events.up.sql \
|
|
internal/db/migrations/0005_events.down.sql \
|
|
internal/db/queries/events.sql \
|
|
internal/db/dbq/events.sql.go
|
|
git commit -m "feat(db): add M2 schema — play_sessions, play_events, skip_events
|
|
|
|
Tables and indexes per spec §5. session_vector_at_play ships nullable
|
|
so M3 doesn't need a follow-up migration. Table is named play_sessions
|
|
to avoid collision with the existing sessions (HTTP auth) table from
|
|
migration 0004."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Events config struct
|
|
|
|
**Files:**
|
|
- Modify: `internal/config/config.go`
|
|
- Modify: `config.example.yaml`
|
|
|
|
- [ ] **Step 1: Open `internal/config/config.go`, add the EventsConfig struct after SubsonicConfig**
|
|
|
|
Insert after the `SubsonicConfig` block:
|
|
|
|
```go
|
|
// EventsConfig governs play-event ingestion: session window, skip rule.
|
|
type EventsConfig struct {
|
|
SessionTimeoutMinutes int `yaml:"session_timeout_minutes"`
|
|
SkipMaxCompletionRatio float64 `yaml:"skip_max_completion_ratio"`
|
|
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add Events field to `Config` struct and Default()**
|
|
|
|
Find the `type Config struct` block. Add `Events EventsConfig \`yaml:"events"\`` to it.
|
|
|
|
Find `func Default() Config` and update:
|
|
|
|
```go
|
|
func Default() Config {
|
|
return Config{
|
|
Server: ServerConfig{Address: ":4533"},
|
|
Database: DatabaseConfig{URL: ""},
|
|
Log: LogConfig{Level: "info", Format: "text"},
|
|
Events: EventsConfig{
|
|
SessionTimeoutMinutes: 30,
|
|
SkipMaxCompletionRatio: 0.5,
|
|
SkipMaxDurationPlayedMs: 30000,
|
|
},
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Append the yaml example**
|
|
|
|
Append to `config.example.yaml`:
|
|
|
|
```yaml
|
|
|
|
events:
|
|
# Idle gap (minutes) between play_events that closes a play_session and
|
|
# starts a new one. Spec §6.
|
|
session_timeout_minutes: 30
|
|
# Skip rule (spec §6): a play counts as a SKIP if completion ratio is below
|
|
# this threshold AND duration played (ms) is below the next threshold.
|
|
skip_max_completion_ratio: 0.5
|
|
skip_max_duration_played_ms: 30000
|
|
```
|
|
|
|
- [ ] **Step 4: Run config tests**
|
|
|
|
Run: `go test ./internal/config -v`
|
|
Expected: PASS (no new tests needed; existing tests should still pass with the additional field defaulting correctly).
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add internal/config/config.go config.example.yaml
|
|
git commit -m "feat(config): add events section (session timeout + skip rule thresholds)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Play sessions service
|
|
|
|
**Files:**
|
|
- Create: `internal/playsessions/service.go`
|
|
- Create: `internal/playsessions/service_test.go`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `internal/playsessions/service_test.go`:
|
|
|
|
```go
|
|
package playsessions
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
func testPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test 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 play_events, skip_events, play_sessions, sessions, users RESTART IDENTITY CASCADE"); err != nil {
|
|
t.Fatalf("truncate: %v", err)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
func seedTestUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
|
|
t.Helper()
|
|
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
|
Username: "tester",
|
|
PasswordHash: "x",
|
|
ApiToken: "x",
|
|
IsAdmin: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
return u.ID
|
|
}
|
|
|
|
func TestFindOrCreate_NoPriorSessionCreatesOne(t *testing.T) {
|
|
pool := testPool(t)
|
|
user := seedTestUser(t, pool)
|
|
q := dbq.New(pool)
|
|
|
|
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
|
id, err := FindOrCreate(context.Background(), q, user, now, "client-a", 30*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("FindOrCreate: %v", err)
|
|
}
|
|
if !id.Valid {
|
|
t.Fatalf("session id not valid")
|
|
}
|
|
row, err := q.GetMostRecentPlaySessionForUser(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("get session: %v", err)
|
|
}
|
|
if row.ID != id {
|
|
t.Errorf("returned id %v, db has %v", id, row.ID)
|
|
}
|
|
if !row.StartedAt.Time.Equal(now) {
|
|
t.Errorf("started_at = %v, want %v", row.StartedAt.Time, now)
|
|
}
|
|
}
|
|
|
|
func TestFindOrCreate_WithinWindowExtendsExisting(t *testing.T) {
|
|
pool := testPool(t)
|
|
user := seedTestUser(t, pool)
|
|
q := dbq.New(pool)
|
|
|
|
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
|
first, err := FindOrCreate(context.Background(), q, user, now, "client-a", 30*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("first: %v", err)
|
|
}
|
|
second, err := FindOrCreate(context.Background(), q, user, now.Add(15*time.Minute), "client-a", 30*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("second: %v", err)
|
|
}
|
|
if first != second {
|
|
t.Errorf("session id changed: %v -> %v", first, second)
|
|
}
|
|
row, err := q.GetMostRecentPlaySessionForUser(context.Background(), user)
|
|
if err != nil {
|
|
t.Fatalf("get session: %v", err)
|
|
}
|
|
if !row.LastEventAt.Time.Equal(now.Add(15 * time.Minute)) {
|
|
t.Errorf("last_event_at = %v", row.LastEventAt.Time)
|
|
}
|
|
if row.TrackCount != 2 {
|
|
t.Errorf("track_count = %d, want 2", row.TrackCount)
|
|
}
|
|
}
|
|
|
|
func TestFindOrCreate_BeyondWindowStartsNew(t *testing.T) {
|
|
pool := testPool(t)
|
|
user := seedTestUser(t, pool)
|
|
q := dbq.New(pool)
|
|
|
|
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
|
first, err := FindOrCreate(context.Background(), q, user, now, "client-a", 30*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("first: %v", err)
|
|
}
|
|
second, err := FindOrCreate(context.Background(), q, user, now.Add(31*time.Minute), "client-a", 30*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("second: %v", err)
|
|
}
|
|
if first == second {
|
|
t.Errorf("expected new session, got same id %v", first)
|
|
}
|
|
}
|
|
|
|
func TestFindOrCreate_AtExactBoundaryExtendsExisting(t *testing.T) {
|
|
pool := testPool(t)
|
|
user := seedTestUser(t, pool)
|
|
q := dbq.New(pool)
|
|
|
|
now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
|
|
first, _ := FindOrCreate(context.Background(), q, user, now, "client-a", 30*time.Minute)
|
|
second, err := FindOrCreate(context.Background(), q, user, now.Add(30*time.Minute), "client-a", 30*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("second: %v", err)
|
|
}
|
|
if first != second {
|
|
t.Errorf("expected same session at exact boundary, got new id %v", second)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests, confirm they fail**
|
|
|
|
Run:
|
|
```bash
|
|
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
|
go test ./internal/playsessions -v
|
|
```
|
|
Expected: FAIL — package `playsessions` does not exist or `FindOrCreate` undefined.
|
|
|
|
- [ ] **Step 3: Implement `internal/playsessions/service.go`**
|
|
|
|
```go
|
|
// Package playsessions implements the listening-session lifecycle from
|
|
// spec §6: a session is a contiguous sequence of plays by a user with no
|
|
// gap longer than the configured timeout (default 30 minutes) between
|
|
// events.
|
|
package playsessions
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// FindOrCreate returns the session id for the given user and event time.
|
|
// If the user's most recent session was last touched within `timeout`, that
|
|
// session is extended (last_event_at and track_count updated). Otherwise a
|
|
// new session is inserted.
|
|
//
|
|
// q is *dbq.Queries; pass either the pool-level Queries or one bound to a
|
|
// transaction so callers can compose this into a larger atomic operation.
|
|
func FindOrCreate(
|
|
ctx context.Context,
|
|
q *dbq.Queries,
|
|
userID pgtype.UUID,
|
|
eventTime time.Time,
|
|
clientID string,
|
|
timeout time.Duration,
|
|
) (pgtype.UUID, error) {
|
|
prev, err := q.GetMostRecentPlaySessionForUser(ctx, userID)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return pgtype.UUID{}, err
|
|
}
|
|
if err == nil {
|
|
// "Within window" includes the exact-boundary case: gap <= timeout extends.
|
|
gap := eventTime.Sub(prev.LastEventAt.Time)
|
|
if gap <= timeout {
|
|
if err := q.TouchPlaySessionLastEvent(ctx, dbq.TouchPlaySessionLastEventParams{
|
|
ID: prev.ID,
|
|
LastEventAt: pgtype.Timestamptz{Time: eventTime, Valid: true},
|
|
}); err != nil {
|
|
return pgtype.UUID{}, err
|
|
}
|
|
return prev.ID, nil
|
|
}
|
|
}
|
|
// Either no prior session, or the gap exceeded the timeout.
|
|
var clientIDPtr *string
|
|
if clientID != "" {
|
|
clientIDPtr = &clientID
|
|
}
|
|
row, err := q.InsertPlaySession(ctx, dbq.InsertPlaySessionParams{
|
|
UserID: userID,
|
|
StartedAt: pgtype.Timestamptz{Time: eventTime, Valid: true},
|
|
ClientID: clientIDPtr,
|
|
})
|
|
if err != nil {
|
|
return pgtype.UUID{}, err
|
|
}
|
|
// Bump track_count to 1 on creation so the contract "every FindOrCreate is
|
|
// the start of one play" stays true (insert leaves track_count default 0).
|
|
if err := q.TouchPlaySessionLastEvent(ctx, dbq.TouchPlaySessionLastEventParams{
|
|
ID: row.ID,
|
|
LastEventAt: pgtype.Timestamptz{Time: eventTime, Valid: true},
|
|
}); err != nil {
|
|
return pgtype.UUID{}, err
|
|
}
|
|
return row.ID, nil
|
|
}
|
|
```
|
|
|
|
Note on `TouchPlaySessionLastEventParams`: sqlc generates this struct with named fields matching the SQL `:exec` parameters. The exact struct field names (`ID`, `LastEventAt`) come from the `id = $1, last_event_at = $2` mapping in the query. If sqlc generates them differently, adjust the field names but keep the function signature.
|
|
|
|
- [ ] **Step 4: Run tests, confirm they pass**
|
|
|
|
Run: same command as Step 2.
|
|
Expected: PASS — 4 tests.
|
|
|
|
- [ ] **Step 5: Lint**
|
|
|
|
Run: `golangci-lint run ./internal/playsessions/...`
|
|
Expected: clean.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/playsessions/
|
|
git commit -m "feat(playsessions): add FindOrCreate session service (30-min rolling window)
|
|
|
|
Per spec §6: returns existing session id when last_event_at is within
|
|
timeout; inserts a new session otherwise. Touch updates last_event_at
|
|
and increments track_count so callers can assume FindOrCreate == one
|
|
play started."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Play events writer
|
|
|
|
**Files:**
|
|
- Create: `internal/playevents/writer.go`
|
|
- Create: `internal/playevents/writer_test.go`
|
|
|
|
This package owns the four write paths and the skip classification rule. It calls `playsessions.FindOrCreate` from `RecordPlayStarted` and `RecordSyntheticCompletedPlay`.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `internal/playevents/writer_test.go`:
|
|
|
|
```go
|
|
package playevents
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
func testPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test 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 play_events, skip_events, play_sessions, sessions, users RESTART IDENTITY CASCADE"); err != nil {
|
|
t.Fatalf("truncate: %v", err)
|
|
}
|
|
if _, err := pool.Exec(context.Background(),
|
|
"TRUNCATE tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
|
|
t.Fatalf("truncate library: %v", err)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
type fixture struct {
|
|
pool *pgxpool.Pool
|
|
q *dbq.Queries
|
|
w *Writer
|
|
user pgtype.UUID
|
|
track pgtype.UUID
|
|
dur int32 // ms
|
|
}
|
|
|
|
func newFixture(t *testing.T, durationMs int32) fixture {
|
|
t.Helper()
|
|
pool := testPool(t)
|
|
q := dbq.New(pool)
|
|
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
|
Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("user: %v", err)
|
|
}
|
|
a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "Artist", SortName: "Artist"})
|
|
if err != nil {
|
|
t.Fatalf("artist: %v", err)
|
|
}
|
|
al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Album", SortTitle: "Album", ArtistID: a.ID})
|
|
if err != nil {
|
|
t.Fatalf("album: %v", err)
|
|
}
|
|
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
|
Title: "Track", AlbumID: al.ID, ArtistID: a.ID,
|
|
FilePath: "/tmp/track-fixture.flac",
|
|
DurationMs: durationMs,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("track: %v", err)
|
|
}
|
|
return fixture{
|
|
pool: pool,
|
|
q: q,
|
|
w: NewWriter(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000),
|
|
user: u.ID,
|
|
track: tr.ID,
|
|
dur: durationMs,
|
|
}
|
|
}
|
|
|
|
func TestRecordPlayStarted_InsertsRow(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)
|
|
}
|
|
if !res.PlayEventID.Valid {
|
|
t.Fatalf("play_event_id invalid")
|
|
}
|
|
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got.EndedAt.Valid {
|
|
t.Errorf("ended_at should be NULL right after start")
|
|
}
|
|
if got.WasSkipped {
|
|
t.Errorf("was_skipped should default false")
|
|
}
|
|
}
|
|
|
|
func TestRecordPlayStarted_AutoClosesPriorOpenRow(t *testing.T) {
|
|
f := newFixture(t, 200_000)
|
|
t1 := time.Now().UTC()
|
|
first, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
|
|
if err != nil {
|
|
t.Fatalf("first: %v", err)
|
|
}
|
|
t2 := t1.Add(45 * time.Second)
|
|
_, err = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t2)
|
|
if err != nil {
|
|
t.Fatalf("second: %v", err)
|
|
}
|
|
prior, err := f.q.GetPlayEventByID(context.Background(), first.PlayEventID)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if !prior.EndedAt.Valid {
|
|
t.Errorf("prior should be closed")
|
|
}
|
|
if !prior.WasSkipped {
|
|
t.Errorf("prior should be marked was_skipped=true (auto-close convention)")
|
|
}
|
|
if prior.DurationPlayedMs == nil || *prior.DurationPlayedMs != 45_000 {
|
|
t.Errorf("duration_played_ms = %v, want 45000", prior.DurationPlayedMs)
|
|
}
|
|
}
|
|
|
|
func TestRecordPlayStarted_AutoCloseCapsAtTrackDuration(t *testing.T) {
|
|
f := newFixture(t, 60_000) // 60s track
|
|
t1 := time.Now().UTC()
|
|
first, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
|
|
// Start a second play 5 minutes later — elapsed > track duration.
|
|
t2 := t1.Add(5 * time.Minute)
|
|
_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t2)
|
|
prior, _ := f.q.GetPlayEventByID(context.Background(), first.PlayEventID)
|
|
if prior.DurationPlayedMs == nil || *prior.DurationPlayedMs != 60_000 {
|
|
t.Errorf("duration_played_ms = %v, want capped at 60000", prior.DurationPlayedMs)
|
|
}
|
|
}
|
|
|
|
func TestRecordPlayEnded_AppliesSkipRule_BothFail_NotSkip(t *testing.T) {
|
|
f := newFixture(t, 200_000)
|
|
t1 := time.Now().UTC()
|
|
res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
|
|
// 49% completion (97_999ms), 30s duration -> 30s satisfies "duration >= 30s"
|
|
// so the rule's AND fails -> NOT a skip.
|
|
t2 := t1.Add(30 * time.Second)
|
|
if err := f.w.RecordPlayEnded(context.Background(), res.PlayEventID, 30_000, t2); err != nil {
|
|
t.Fatalf("RecordPlayEnded: %v", err)
|
|
}
|
|
got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
|
|
if got.WasSkipped {
|
|
t.Errorf("was_skipped = true, want false (duration_played_ms == 30000 fails the AND)")
|
|
}
|
|
// No skip_events row should be written.
|
|
}
|
|
|
|
func TestRecordPlayEnded_AppliesSkipRule_BothPass_IsSkip(t *testing.T) {
|
|
f := newFixture(t, 200_000)
|
|
t1 := time.Now().UTC()
|
|
res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
|
|
// 5% completion (10_000ms), 10s duration -> both conditions hold -> SKIP.
|
|
t2 := t1.Add(10 * time.Second)
|
|
if err := f.w.RecordPlayEnded(context.Background(), res.PlayEventID, 10_000, t2); err != nil {
|
|
t.Fatalf("RecordPlayEnded: %v", err)
|
|
}
|
|
got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
|
|
if !got.WasSkipped {
|
|
t.Errorf("expected was_skipped=true")
|
|
}
|
|
// Verify a matching skip_events row was inserted.
|
|
rows, err := f.pool.Query(context.Background(),
|
|
"SELECT id FROM skip_events WHERE user_id=$1 AND track_id=$2", f.user, f.track)
|
|
if err != nil {
|
|
t.Fatalf("query skips: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
count := 0
|
|
for rows.Next() {
|
|
count++
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("skip_events count = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestRecordPlayEnded_DurationOver50Percent_NotSkip(t *testing.T) {
|
|
f := newFixture(t, 200_000) // 200s track
|
|
t1 := time.Now().UTC()
|
|
res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
|
|
// 50% completion (100_000ms), 5s duration. Completion fails the "< 0.5"
|
|
// check — so AND fails, NOT a skip.
|
|
t2 := t1.Add(5 * time.Second)
|
|
_ = f.w.RecordPlayEnded(context.Background(), res.PlayEventID, 100_000, t2)
|
|
got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
|
|
if got.WasSkipped {
|
|
t.Errorf("expected was_skipped=false at completion >= 0.5")
|
|
}
|
|
}
|
|
|
|
func TestRecordPlaySkipped_AlwaysSkipFlagAndSkipEventRow(t *testing.T) {
|
|
f := newFixture(t, 200_000)
|
|
t1 := time.Now().UTC()
|
|
res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
|
|
// User-initiated skip at 90% — overrides the rule, always treated as skip.
|
|
t2 := t1.Add(180 * time.Second)
|
|
if err := f.w.RecordPlaySkipped(context.Background(), res.PlayEventID, 180_000, t2); err != nil {
|
|
t.Fatalf("RecordPlaySkipped: %v", err)
|
|
}
|
|
got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
|
|
if !got.WasSkipped {
|
|
t.Errorf("RecordPlaySkipped must always set was_skipped=true")
|
|
}
|
|
}
|
|
|
|
func TestRecordSyntheticCompletedPlay_WritesBothRows(t *testing.T) {
|
|
f := newFixture(t, 200_000)
|
|
now := time.Now().UTC()
|
|
if err := f.w.RecordSyntheticCompletedPlay(context.Background(), f.user, f.track, "feishin", now); err != nil {
|
|
t.Fatalf("synthetic: %v", err)
|
|
}
|
|
rows, _ := f.pool.Query(context.Background(),
|
|
"SELECT id, started_at, ended_at, duration_played_ms, was_skipped FROM play_events WHERE user_id=$1", f.user)
|
|
defer rows.Close()
|
|
count := 0
|
|
for rows.Next() {
|
|
count++
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("play_events count = %d, want 1", count)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests, confirm they fail**
|
|
|
|
Run:
|
|
```bash
|
|
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
|
go test ./internal/playevents -v
|
|
```
|
|
Expected: FAIL — package not yet present.
|
|
|
|
- [ ] **Step 3: Implement `internal/playevents/writer.go`**
|
|
|
|
```go
|
|
// Package playevents owns the four write paths into play_events / skip_events:
|
|
// RecordPlayStarted, RecordPlayEnded, RecordPlaySkipped, and
|
|
// RecordSyntheticCompletedPlay (used by the Subsonic /rest/scrobble shim).
|
|
//
|
|
// All paths run inside a single transaction. The auto-close-prior step on
|
|
// RecordPlayStarted bounds each user to at most one open play_event at any
|
|
// time (per spec data-flow section).
|
|
package playevents
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playsessions"
|
|
)
|
|
|
|
type Writer struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
sessionTimeout time.Duration
|
|
skipMaxCompletionRatio float64
|
|
skipMaxDurationPlayedMs int32
|
|
}
|
|
|
|
// NewWriter constructs a writer. timeout is the play-session window; the two
|
|
// remaining args are the skip rule thresholds from spec §6.
|
|
func NewWriter(
|
|
pool *pgxpool.Pool,
|
|
logger *slog.Logger,
|
|
sessionTimeout time.Duration,
|
|
skipMaxCompletionRatio float64,
|
|
skipMaxDurationPlayedMs int,
|
|
) *Writer {
|
|
return &Writer{
|
|
pool: pool,
|
|
logger: logger,
|
|
sessionTimeout: sessionTimeout,
|
|
skipMaxCompletionRatio: skipMaxCompletionRatio,
|
|
skipMaxDurationPlayedMs: int32(skipMaxDurationPlayedMs),
|
|
}
|
|
}
|
|
|
|
type StartedResult struct {
|
|
PlayEventID pgtype.UUID
|
|
SessionID pgtype.UUID
|
|
}
|
|
|
|
// RecordPlayStarted: in one transaction, auto-close any prior open row for
|
|
// the user, FindOrCreate the play_session, insert the new play_event,
|
|
// return its id and session id.
|
|
func (w *Writer) RecordPlayStarted(
|
|
ctx context.Context,
|
|
userID, trackID pgtype.UUID,
|
|
clientID string,
|
|
at time.Time,
|
|
) (StartedResult, error) {
|
|
var out StartedResult
|
|
err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
|
|
q := dbq.New(tx)
|
|
// Auto-close any prior open row for this user.
|
|
if err := w.autoClosePriorOpen(ctx, q, userID, at); err != nil {
|
|
return err
|
|
}
|
|
// Resolve session.
|
|
sessionID, err := playsessions.FindOrCreate(ctx, q, userID, at, clientID, w.sessionTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var clientIDPtr *string
|
|
if clientID != "" {
|
|
clientIDPtr = &clientID
|
|
}
|
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
|
UserID: userID,
|
|
TrackID: trackID,
|
|
SessionID: sessionID,
|
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
ClientID: clientIDPtr,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out.PlayEventID = ev.ID
|
|
out.SessionID = sessionID
|
|
return nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// RecordPlayEnded closes a play_event with duration + computed completion
|
|
// ratio. Applies the spec §6 skip rule (AND of completion < threshold AND
|
|
// duration_played < threshold) — both conditions must hold for the row to
|
|
// be marked was_skipped=true. If skipped, also writes a skip_events row.
|
|
func (w *Writer) RecordPlayEnded(
|
|
ctx context.Context,
|
|
playEventID pgtype.UUID,
|
|
durationPlayedMs int32,
|
|
at time.Time,
|
|
) error {
|
|
return pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
|
|
q := dbq.New(tx)
|
|
ev, err := q.GetPlayEventByID(ctx, playEventID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Look up track duration to compute completion_ratio.
|
|
track, err := q.GetTrackByID(ctx, ev.TrackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ratio := 0.0
|
|
if track.DurationMs > 0 {
|
|
ratio = float64(durationPlayedMs) / float64(track.DurationMs)
|
|
}
|
|
// Skip rule: both conditions must hold.
|
|
isSkip := ratio < w.skipMaxCompletionRatio && durationPlayedMs < w.skipMaxDurationPlayedMs
|
|
ratioPtr := ratio
|
|
durPtr := durationPlayedMs
|
|
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
|
ID: playEventID,
|
|
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
DurationPlayedMs: &durPtr,
|
|
CompletionRatio: &ratioPtr,
|
|
WasSkipped: isSkip,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if isSkip {
|
|
if _, err := q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{
|
|
UserID: ev.UserID,
|
|
TrackID: ev.TrackID,
|
|
SessionID: ev.SessionID,
|
|
SkippedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
PositionMs: durationPlayedMs,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// RecordPlaySkipped is the user-initiated-skip variant: was_skipped is forced
|
|
// true regardless of the rule, and a skip_events row is always written.
|
|
func (w *Writer) RecordPlaySkipped(
|
|
ctx context.Context,
|
|
playEventID pgtype.UUID,
|
|
positionMs int32,
|
|
at time.Time,
|
|
) error {
|
|
return pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
|
|
q := dbq.New(tx)
|
|
ev, err := q.GetPlayEventByID(ctx, playEventID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
track, err := q.GetTrackByID(ctx, ev.TrackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ratio := 0.0
|
|
if track.DurationMs > 0 {
|
|
ratio = float64(positionMs) / float64(track.DurationMs)
|
|
}
|
|
ratioPtr := ratio
|
|
durPtr := positionMs
|
|
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
|
ID: playEventID,
|
|
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
DurationPlayedMs: &durPtr,
|
|
CompletionRatio: &ratioPtr,
|
|
WasSkipped: true,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
_, err = q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{
|
|
UserID: ev.UserID,
|
|
TrackID: ev.TrackID,
|
|
SessionID: ev.SessionID,
|
|
SkippedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
PositionMs: positionMs,
|
|
})
|
|
return err
|
|
})
|
|
}
|
|
|
|
// RecordSyntheticCompletedPlay writes a start+end pair for a Subsonic
|
|
// scrobble?submission=true call. ended_at = at + track.duration_ms,
|
|
// duration_played_ms = track.duration_ms, was_skipped = false.
|
|
func (w *Writer) RecordSyntheticCompletedPlay(
|
|
ctx context.Context,
|
|
userID, trackID pgtype.UUID,
|
|
clientID string,
|
|
at time.Time,
|
|
) error {
|
|
return pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
|
|
q := dbq.New(tx)
|
|
track, err := q.GetTrackByID(ctx, trackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := w.autoClosePriorOpen(ctx, q, userID, at); err != nil {
|
|
return err
|
|
}
|
|
sessionID, err := playsessions.FindOrCreate(ctx, q, userID, at, clientID, w.sessionTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var clientIDPtr *string
|
|
if clientID != "" {
|
|
clientIDPtr = &clientID
|
|
}
|
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
|
UserID: userID,
|
|
TrackID: trackID,
|
|
SessionID: sessionID,
|
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
ClientID: clientIDPtr,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ratio := 1.0
|
|
dur := track.DurationMs
|
|
_, err = q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
|
ID: ev.ID,
|
|
EndedAt: pgtype.Timestamptz{Time: at.Add(time.Duration(track.DurationMs) * time.Millisecond), Valid: true},
|
|
DurationPlayedMs: &dur,
|
|
CompletionRatio: &ratio,
|
|
WasSkipped: false,
|
|
})
|
|
return err
|
|
})
|
|
}
|
|
|
|
// autoClosePriorOpen closes any open (ended_at IS NULL) play_event for the
|
|
// user. Sets ended_at = at, duration_played_ms = min(at - started_at, track
|
|
// duration), was_skipped = true. Skip rule is NOT applied — auto-closed
|
|
// rows are flagged skipped regardless because we don't know what the user
|
|
// actually heard.
|
|
func (w *Writer) autoClosePriorOpen(
|
|
ctx context.Context,
|
|
q *dbq.Queries,
|
|
userID pgtype.UUID,
|
|
at time.Time,
|
|
) error {
|
|
prior, err := q.GetOpenPlayEventForUser(ctx, userID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
track, err := q.GetTrackByID(ctx, prior.TrackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
elapsedMs := int32(at.Sub(prior.StartedAt.Time) / time.Millisecond)
|
|
if elapsedMs < 0 {
|
|
elapsedMs = 0
|
|
}
|
|
if elapsedMs > track.DurationMs && track.DurationMs > 0 {
|
|
elapsedMs = track.DurationMs
|
|
}
|
|
ratio := 0.0
|
|
if track.DurationMs > 0 {
|
|
ratio = float64(elapsedMs) / float64(track.DurationMs)
|
|
}
|
|
ratioPtr := ratio
|
|
durPtr := elapsedMs
|
|
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
|
ID: prior.ID,
|
|
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
DurationPlayedMs: &durPtr,
|
|
CompletionRatio: &ratioPtr,
|
|
WasSkipped: true,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
w.logger.Info("playevents: auto-closed prior open row",
|
|
"play_event_id", prior.ID, "elapsed_ms", elapsedMs)
|
|
return nil
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, confirm they pass**
|
|
|
|
Run: same as Step 2.
|
|
Expected: PASS — 8 tests.
|
|
|
|
- [ ] **Step 5: Lint**
|
|
|
|
Run: `golangci-lint run ./internal/playevents/...`
|
|
Expected: clean.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add internal/playevents/
|
|
git commit -m "feat(playevents): add Writer with start/end/skipped/synthetic paths
|
|
|
|
Owns the auto-close-prior step (caps each user at one open row),
|
|
spec §6 skip classification rule on play_ended (AND of completion <
|
|
0.5 and duration_played < 30s), and skip_events row writes. Synthetic
|
|
completed play wires the Subsonic /rest/scrobble?submission=true path
|
|
into the same store as native events."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: HTTP `POST /api/events` handler
|
|
|
|
**Files:**
|
|
- Create: `internal/api/events.go`
|
|
- Create: `internal/api/events_test.go`
|
|
- Modify: `internal/api/api.go`
|
|
- Modify: `internal/api/auth_test.go` (extend `testHandlers` to inject events writer)
|
|
|
|
The handler is a thin wrapper around `playevents.Writer`. Auth comes from the existing `RequireUser` middleware; user_id is read from request context.
|
|
|
|
- [ ] **Step 1: Extend `testHandlers` to construct a Writer**
|
|
|
|
The existing `testHandlers` returns `(*handlers, *pgxpool.Pool)`. The events writer needs a `*playevents.Writer`, which requires the pool + config thresholds. We attach it to the `handlers` struct.
|
|
|
|
In `internal/api/api.go`, modify the `handlers` struct and `Mount` to thread the writer through. Add to the file:
|
|
|
|
```go
|
|
import (
|
|
// ... existing imports ...
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
|
)
|
|
|
|
// (existing Mount signature, just add cfg parameter at the end)
|
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg config.EventsConfig) {
|
|
w := playevents.NewWriter(
|
|
pool, logger,
|
|
time.Duration(cfg.SessionTimeoutMinutes)*time.Minute,
|
|
cfg.SkipMaxCompletionRatio,
|
|
cfg.SkipMaxDurationPlayedMs,
|
|
)
|
|
h := &handlers{pool: pool, logger: logger, events: w}
|
|
// ... rest unchanged ...
|
|
}
|
|
|
|
type handlers struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
events *playevents.Writer
|
|
}
|
|
```
|
|
|
|
The `Mount` callers need updating. Find every call site (`grep -rn "api.Mount" --include='*.go'`) and add `cfg.Events` as the new argument. There's likely one in `cmd/minstrel/main.go` and possibly one in `internal/server`.
|
|
|
|
In `internal/api/auth_test.go`, modify `testHandlers` to construct a Writer:
|
|
|
|
```go
|
|
import (
|
|
// ... existing imports ...
|
|
"time"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
|
)
|
|
|
|
func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("skipping api integration in -short mode")
|
|
}
|
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
if err := db.Migrate(dsn, logger); 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 play_events, skip_events, play_sessions, sessions, users RESTART IDENTITY CASCADE"); err != nil {
|
|
t.Fatalf("truncate: %v", err)
|
|
}
|
|
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
|
return &handlers{pool: pool, logger: logger, events: w}, pool
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing tests**
|
|
|
|
Create `internal/api/events_test.go`:
|
|
|
|
```go
|
|
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// callEvents invokes h.handleEvents directly, injecting `user` via the same
|
|
// context key RequireUser populates in production. Mirrors the pattern in
|
|
// me_test.go (userCtxKeyForTest comes from auth_test.go).
|
|
func callEvents(h *handlers, user dbq.User, body []byte) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/events", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
|
|
w := httptest.NewRecorder()
|
|
h.handleEvents(w, req)
|
|
return w
|
|
}
|
|
|
|
func TestHandleEvents_PlayStartedReturnsIDs(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)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"type": "play_started",
|
|
"track_id": uuidToString(track.ID),
|
|
})
|
|
w := callEvents(h, user, body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
PlayEventID string `json:"play_event_id"`
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.PlayEventID == "" || resp.SessionID == "" {
|
|
t.Errorf("ids empty: %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestHandleEvents_PlayEndedClosesRow(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)
|
|
|
|
startedID := postPlayStartedHelper(t, h, user, track.ID)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"type": "play_ended",
|
|
"play_event_id": startedID,
|
|
"duration_played_ms": 180_000,
|
|
})
|
|
w := callEvents(h, user, body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ended status = %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleEvents_MissingTypeIs400(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
user := seedUser(t, pool, "alice", "x", false)
|
|
body, _ := json.Marshal(map[string]any{"track_id": "00000000-0000-0000-0000-000000000000"})
|
|
w := callEvents(h, user, body)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleEvents_PlayStartedWithUnknownTrackIs404(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
user := seedUser(t, pool, "alice", "x", false)
|
|
body, _ := json.Marshal(map[string]any{
|
|
"type": "play_started",
|
|
"track_id": "00000000-0000-0000-0000-000000000000",
|
|
})
|
|
w := callEvents(h, user, body)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleEvents_PlayEndedOnOtherUsersRowIs403(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, "X", 1, 100_000)
|
|
|
|
startedID := postPlayStartedHelper(t, h, alice, track.ID)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"type": "play_ended",
|
|
"play_event_id": startedID,
|
|
"duration_played_ms": 50_000,
|
|
})
|
|
w := callEvents(h, bob, body)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403", w.Code)
|
|
}
|
|
}
|
|
|
|
func postPlayStartedHelper(t *testing.T, h *handlers, user dbq.User, trackID dbq.UUIDLike) string {
|
|
t.Helper()
|
|
body, _ := json.Marshal(map[string]any{
|
|
"type": "play_started",
|
|
"track_id": uuidToString(trackID),
|
|
})
|
|
w := callEvents(h, user, body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("play_started status = %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct{ PlayEventID string `json:"play_event_id"` }
|
|
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
|
return resp.PlayEventID
|
|
}
|
|
```
|
|
|
|
Note: `uuidToString` already exists in this package (used by other handlers); reuse it. `dbq.UUIDLike` in `postPlayStartedHelper` is a placeholder — in practice you'll declare the parameter as `pgtype.UUID` and import the package. The existing test fixtures (`seedTrack` etc.) return `dbq.Track` whose `ID` field is `pgtype.UUID`, so the helper takes `pgtype.UUID` directly:
|
|
|
|
```go
|
|
func postPlayStartedHelper(t *testing.T, h *handlers, user dbq.User, trackID pgtype.UUID) string {
|
|
```
|
|
|
|
(adjust the import list accordingly).
|
|
|
|
- [ ] **Step 3: Run tests, confirm they fail**
|
|
|
|
Run:
|
|
```bash
|
|
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
|
go test ./internal/api -run TestHandleEvents -v
|
|
```
|
|
Expected: FAIL — `handleEvents` undefined.
|
|
|
|
- [ ] **Step 4: Implement `internal/api/events.go`**
|
|
|
|
```go
|
|
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
type eventRequest struct {
|
|
Type string `json:"type"`
|
|
TrackID string `json:"track_id"`
|
|
PlayEventID string `json:"play_event_id"`
|
|
DurationPlayedMs *int32 `json:"duration_played_ms"`
|
|
PositionMs *int32 `json:"position_ms"`
|
|
At *string `json:"at"`
|
|
ClientID *string `json:"client_id"`
|
|
}
|
|
|
|
type playStartedResponse struct {
|
|
PlayEventID string `json:"play_event_id"`
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
type okResponse struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
|
|
func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
|
|
return
|
|
}
|
|
var req eventRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
|
|
return
|
|
}
|
|
at := time.Now().UTC()
|
|
if req.At != nil && *req.At != "" {
|
|
parsed, err := time.Parse(time.RFC3339, *req.At)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid `at` timestamp")
|
|
return
|
|
}
|
|
at = parsed
|
|
}
|
|
clientID := ""
|
|
if req.ClientID != nil {
|
|
clientID = *req.ClientID
|
|
}
|
|
switch req.Type {
|
|
case "play_started":
|
|
h.handleEventPlayStarted(w, r, user, req, at, clientID)
|
|
case "play_ended":
|
|
h.handleEventPlayEnded(w, r, user, req, at)
|
|
case "play_skipped":
|
|
h.handleEventPlaySkipped(w, r, user, req, at)
|
|
default:
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "unknown event type")
|
|
}
|
|
}
|
|
|
|
func (h *handlers) handleEventPlayStarted(
|
|
w http.ResponseWriter, r *http.Request,
|
|
user dbq.User, req eventRequest, at time.Time, clientID string,
|
|
) {
|
|
trackID, ok := parseUUID(req.TrackID)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track_id")
|
|
return
|
|
}
|
|
if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErr(w, http.StatusNotFound, "not_found", "track not found")
|
|
return
|
|
}
|
|
h.logger.Error("api: events: lookup track", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
|
return
|
|
}
|
|
res, err := h.events.RecordPlayStarted(r.Context(), user.ID, trackID, clientID, at)
|
|
if err != nil {
|
|
h.logger.Error("api: events: play_started", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error", "record failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, playStartedResponse{
|
|
PlayEventID: uuidToString(res.PlayEventID),
|
|
SessionID: uuidToString(res.SessionID),
|
|
})
|
|
}
|
|
|
|
func (h *handlers) handleEventPlayEnded(
|
|
w http.ResponseWriter, r *http.Request,
|
|
user dbq.User, req eventRequest, at time.Time,
|
|
) {
|
|
playEventID, ok := parseUUID(req.PlayEventID)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid play_event_id")
|
|
return
|
|
}
|
|
if req.DurationPlayedMs == nil || *req.DurationPlayedMs < 0 {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "duration_played_ms required and must be >= 0")
|
|
return
|
|
}
|
|
ev, err := dbq.New(h.pool).GetPlayEventByID(r.Context(), playEventID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErr(w, http.StatusNotFound, "not_found", "play_event not found")
|
|
return
|
|
}
|
|
h.logger.Error("api: events: lookup play_event", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
|
return
|
|
}
|
|
if ev.UserID != user.ID {
|
|
writeErr(w, http.StatusForbidden, "forbidden", "play_event belongs to a different user")
|
|
return
|
|
}
|
|
if err := h.events.RecordPlayEnded(r.Context(), playEventID, *req.DurationPlayedMs, at); err != nil {
|
|
h.logger.Error("api: events: play_ended", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error", "record failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
|
}
|
|
|
|
func (h *handlers) handleEventPlaySkipped(
|
|
w http.ResponseWriter, r *http.Request,
|
|
user dbq.User, req eventRequest, at time.Time,
|
|
) {
|
|
playEventID, ok := parseUUID(req.PlayEventID)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "invalid play_event_id")
|
|
return
|
|
}
|
|
if req.PositionMs == nil || *req.PositionMs < 0 {
|
|
writeErr(w, http.StatusBadRequest, "bad_request", "position_ms required and must be >= 0")
|
|
return
|
|
}
|
|
ev, err := dbq.New(h.pool).GetPlayEventByID(r.Context(), playEventID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErr(w, http.StatusNotFound, "not_found", "play_event not found")
|
|
return
|
|
}
|
|
h.logger.Error("api: events: lookup play_event", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
|
return
|
|
}
|
|
if ev.UserID != user.ID {
|
|
writeErr(w, http.StatusForbidden, "forbidden", "play_event belongs to a different user")
|
|
return
|
|
}
|
|
if err := h.events.RecordPlaySkipped(r.Context(), playEventID, *req.PositionMs, at); err != nil {
|
|
h.logger.Error("api: events: play_skipped", "err", err)
|
|
writeErr(w, http.StatusInternalServerError, "server_error", "record failed")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, okResponse{OK: true})
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Register the route in `internal/api/api.go`**
|
|
|
|
Find the authed group and add (right after `authed.Get("/radio", h.handleRadio)` from the prior PR):
|
|
|
|
```go
|
|
authed.Post("/events", h.handleEvents)
|
|
```
|
|
|
|
- [ ] **Step 6: Run tests, confirm pass**
|
|
|
|
Run: same command as Step 3.
|
|
Expected: PASS — 5 tests.
|
|
|
|
- [ ] **Step 7: Lint + full Go suite**
|
|
|
|
Run: `golangci-lint run ./...`
|
|
Expected: clean.
|
|
|
|
Run: `go test -short -race ./...`
|
|
Expected: all packages PASS.
|
|
|
|
Run with the integration env var:
|
|
```bash
|
|
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
|
go test -race ./...
|
|
```
|
|
Expected: PASS including the playsessions, playevents, and api integration tests.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add internal/api/events.go internal/api/events_test.go internal/api/api.go internal/api/auth_test.go cmd/minstrel/main.go internal/server/
|
|
git commit -m "feat(api): add POST /api/events handler
|
|
|
|
Discriminated-union JSON over three event types: play_started,
|
|
play_ended, play_skipped. Auth via existing RequireUser. play_started
|
|
returns play_event_id + session_id; the other two return { ok: true }.
|
|
Validates ownership: play_ended/play_skipped on another user's row
|
|
returns 403."
|
|
```
|
|
|
|
(If the cmd/minstrel/main.go or internal/server changes touch other unrelated files, stage them separately.)
|
|
|
|
---
|
|
|
|
## Task 6: Subsonic scrobble integration
|
|
|
|
**Files:**
|
|
- Modify: `internal/subsonic/stream.go`
|
|
- Modify: `internal/subsonic/subsonic.go` (constructor wiring)
|
|
- Create: `internal/subsonic/scrobble_test.go`
|
|
|
|
Replace the in-memory `nowPlayingMap` with calls into `playevents.Writer`. The constructor for `mediaHandlers` grows a writer arg.
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Create `internal/subsonic/scrobble_test.go`:
|
|
|
|
```go
|
|
package subsonic
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"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 testScrobblePool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track) {
|
|
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 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, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
|
Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("user: %v", err)
|
|
}
|
|
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/scrob.flac", DurationMs: 100_000,
|
|
})
|
|
return pool, u, tr
|
|
}
|
|
|
|
func newScrobbleHandlers(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 TestHandleScrobble_SubmissionFalseInsertsOpenPlayEvent(t *testing.T) {
|
|
pool, user, track := testScrobblePool(t)
|
|
m := newScrobbleHandlers(pool)
|
|
|
|
q := url.Values{}
|
|
q.Set("id", uuidToID(track.ID))
|
|
q.Set("submission", "false")
|
|
req := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q.Encode(), nil)
|
|
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
|
w := httptest.NewRecorder()
|
|
m.handleScrobble(w, req.WithContext(ctx))
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", w.Code)
|
|
}
|
|
rows, _ := pool.Query(context.Background(),
|
|
"SELECT id FROM play_events WHERE user_id=$1 AND ended_at IS NULL", user.ID)
|
|
defer rows.Close()
|
|
count := 0
|
|
for rows.Next() {
|
|
count++
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("open play_events count = %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestHandleScrobble_SubmissionTrueWritesCompletedPlay(t *testing.T) {
|
|
pool, user, track := testScrobblePool(t)
|
|
m := newScrobbleHandlers(pool)
|
|
|
|
q := url.Values{}
|
|
q.Set("id", uuidToID(track.ID))
|
|
q.Set("submission", "true")
|
|
req := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q.Encode(), nil)
|
|
ctx := context.WithValue(req.Context(), userCtxKey, user)
|
|
w := httptest.NewRecorder()
|
|
m.handleScrobble(w, req.WithContext(ctx))
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", w.Code)
|
|
}
|
|
rows, _ := pool.Query(context.Background(),
|
|
"SELECT ended_at IS NOT NULL FROM play_events WHERE user_id=$1", user.ID)
|
|
defer rows.Close()
|
|
closed := 0
|
|
for rows.Next() {
|
|
var c bool
|
|
_ = rows.Scan(&c)
|
|
if c {
|
|
closed++
|
|
}
|
|
}
|
|
if closed != 1 {
|
|
t.Errorf("closed play_events = %d, want 1", closed)
|
|
}
|
|
}
|
|
|
|
// uuidToID is the existing helper in internal/subsonic; same-package
|
|
// test reuses it. pgtype.UUID kept as an explicit import marker.
|
|
var _ = pgtype.UUID{}
|
|
```
|
|
|
|
Note: the existing `internal/subsonic/stream.go` already has a `uuidToID` helper used by handlers. The test refers to it directly. If the test file conflicts with a private function name, rename the local one or import-alias.
|
|
|
|
- [ ] **Step 2: Modify `internal/subsonic/stream.go`**
|
|
|
|
Replace the `mediaHandlers` struct and its constructor:
|
|
|
|
```go
|
|
// mediaHandlers serves the bytes-on-the-wire endpoints: stream, download,
|
|
// getCoverArt, scrobble.
|
|
type mediaHandlers struct {
|
|
pool *pgxpool.Pool
|
|
events *playevents.Writer
|
|
}
|
|
|
|
func newMediaHandlers(pool *pgxpool.Pool, events *playevents.Writer) *mediaHandlers {
|
|
return &mediaHandlers{pool: pool, events: events}
|
|
}
|
|
```
|
|
|
|
Add the import: `"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"`.
|
|
|
|
Replace `handleScrobble`:
|
|
|
|
```go
|
|
// handleScrobble translates Subsonic scrobble calls into native play_events.
|
|
//
|
|
// submission=false (now-playing): writes a play_started, leaves ended_at NULL.
|
|
// submission=true (completed): writes a synthetic completed play (start+end
|
|
// in one transaction).
|
|
func (m *mediaHandlers) handleScrobble(w http.ResponseWriter, r *http.Request) {
|
|
params := r.URL.Query()
|
|
idStr := params.Get("id")
|
|
if idStr == "" {
|
|
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
|
|
return
|
|
}
|
|
trackID, ok := parseUUID(idStr)
|
|
if !ok {
|
|
WriteFail(w, r, ErrDataNotFound, "Track not found")
|
|
return
|
|
}
|
|
user, ok := UserFromContext(r.Context())
|
|
if !ok {
|
|
WriteFail(w, r, ErrGeneric, "Unauthenticated")
|
|
return
|
|
}
|
|
at := time.Now().UTC()
|
|
if t := params.Get("time"); t != "" {
|
|
// Subsonic sends `time` as ms since epoch (per spec). Some clients send
|
|
// it as ISO 8601; accept both for resilience.
|
|
if ms, err := strconv.ParseInt(t, 10, 64); err == nil {
|
|
at = time.UnixMilli(ms).UTC()
|
|
} else if parsed, err := time.Parse(time.RFC3339, t); err == nil {
|
|
at = parsed
|
|
}
|
|
// Otherwise fall through to now() — better than rejecting the call.
|
|
}
|
|
clientID := params.Get("c")
|
|
|
|
submission := strings.ToLower(params.Get("submission"))
|
|
switch submission {
|
|
case "true", "":
|
|
// Subsonic default is submission=true (completed play).
|
|
if err := m.events.RecordSyntheticCompletedPlay(r.Context(), user.ID, trackID, clientID, at); err != nil {
|
|
WriteFail(w, r, ErrGeneric, "Could not record play")
|
|
return
|
|
}
|
|
case "false":
|
|
if _, err := m.events.RecordPlayStarted(r.Context(), user.ID, trackID, clientID, at); err != nil {
|
|
WriteFail(w, r, ErrGeneric, "Could not record now-playing")
|
|
return
|
|
}
|
|
default:
|
|
// Unknown submission values: ack and ignore.
|
|
}
|
|
Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
|
|
}
|
|
```
|
|
|
|
Add the imports `"strconv"` and `"time"` if not already present. Delete the entire `nowPlayingMap` struct + `nowPlayingEntry` + `newNowPlayingMap` + the `nowPlaying` field reference. The file ends much shorter after this change.
|
|
|
|
- [ ] **Step 3: Update the constructor in `internal/subsonic/subsonic.go`**
|
|
|
|
Find where `newMediaHandlers(pool)` is called (likely in `Mount` or the equivalent setup func). Update the signature and pass the writer through:
|
|
|
|
```go
|
|
// Before:
|
|
// m := newMediaHandlers(pool)
|
|
// After (Mount signature gains a *playevents.Writer parameter):
|
|
// func Mount(r chi.Router, pool *pgxpool.Pool, events *playevents.Writer) {
|
|
// m := newMediaHandlers(pool, events)
|
|
// ...
|
|
// }
|
|
```
|
|
|
|
The caller chain: cmd/minstrel/main.go (or wherever `subsonic.Mount` is called) needs to be updated to pass the writer. Since the api Mount and subsonic Mount are likely both called from the same setup, share the writer between them.
|
|
|
|
- [ ] **Step 4: Run tests, confirm they pass**
|
|
|
|
Run:
|
|
```bash
|
|
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
|
go test ./internal/subsonic -run TestHandleScrobble -v
|
|
```
|
|
Expected: PASS — 2 tests.
|
|
|
|
- [ ] **Step 5: Full Go suite**
|
|
|
|
Run:
|
|
```bash
|
|
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
|
go test -race ./...
|
|
```
|
|
Expected: PASS across all packages.
|
|
|
|
- [ ] **Step 6: Lint**
|
|
|
|
Run: `golangci-lint run ./...`
|
|
Expected: clean.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add internal/subsonic/ cmd/minstrel/main.go internal/server/
|
|
git commit -m "feat(subsonic): wire /rest/scrobble into playevents.Writer
|
|
|
|
submission=false → play_started (replaces in-memory nowPlayingMap).
|
|
submission=true (default) → synthetic completed play. The nowPlayingMap
|
|
struct + factory + the nowPlaying field on mediaHandlers are deleted;
|
|
play_events WHERE ended_at IS NULL is now the source of truth for
|
|
'currently playing,' reachable from M3+ work."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Web client — clientId + events module
|
|
|
|
**Files:**
|
|
- Create: `web/src/lib/player/clientId.ts`
|
|
- Create: `web/src/lib/player/clientId.test.ts`
|
|
- Create: `web/src/lib/player/events.svelte.ts`
|
|
- Create: `web/src/lib/player/events.svelte.test.ts`
|
|
- Modify: `web/src/lib/api/types.ts`
|
|
|
|
- [ ] **Step 1: Add types**
|
|
|
|
Open `web/src/lib/api/types.ts`. After the existing `RadioResponse`, append:
|
|
|
|
```ts
|
|
export type EventRequest =
|
|
| { type: 'play_started'; track_id: string; client_id?: string }
|
|
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number }
|
|
| { type: 'play_skipped'; play_event_id: string; position_ms: number };
|
|
|
|
export type PlayStartedResponse = { play_event_id: string; session_id: string };
|
|
export type EventOkResponse = { ok: true };
|
|
```
|
|
|
|
- [ ] **Step 2: Write the clientId test**
|
|
|
|
Create `web/src/lib/player/clientId.test.ts`:
|
|
|
|
```ts
|
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
import { getOrCreateClientId } from './clientId';
|
|
|
|
beforeEach(() => sessionStorage.clear());
|
|
afterEach(() => sessionStorage.clear());
|
|
|
|
describe('getOrCreateClientId', () => {
|
|
test('generates a UUID on first call and persists it in sessionStorage', () => {
|
|
const id = getOrCreateClientId();
|
|
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
|
expect(sessionStorage.getItem('minstrel.client_id')).toBe(id);
|
|
});
|
|
|
|
test('returns the same id on subsequent calls', () => {
|
|
const a = getOrCreateClientId();
|
|
const b = getOrCreateClientId();
|
|
expect(a).toBe(b);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: Run, confirm fail**
|
|
|
|
Run: `cd web && npm test -- src/lib/player/clientId.test.ts`
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 4: Implement `web/src/lib/player/clientId.ts`**
|
|
|
|
```ts
|
|
const KEY = 'minstrel.client_id';
|
|
|
|
export function getOrCreateClientId(): string {
|
|
try {
|
|
const existing = sessionStorage.getItem(KEY);
|
|
if (existing) return existing;
|
|
} catch {
|
|
// sessionStorage unavailable (non-browser env). Generate ephemerally.
|
|
}
|
|
const id = crypto.randomUUID();
|
|
try { sessionStorage.setItem(KEY, id); } catch { /* ignore */ }
|
|
return id;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run, confirm pass**
|
|
|
|
Run: `cd web && npm test -- src/lib/player/clientId.test.ts`
|
|
Expected: PASS — 2 tests.
|
|
|
|
- [ ] **Step 6: Write the events.svelte tests**
|
|
|
|
Create `web/src/lib/player/events.svelte.test.ts`:
|
|
|
|
```ts
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
import { flushSync } from 'svelte';
|
|
|
|
vi.mock('$lib/api/client', () => ({
|
|
api: {
|
|
post: vi.fn().mockResolvedValue({ play_event_id: 'pe-1', session_id: 's-1' })
|
|
}
|
|
}));
|
|
|
|
vi.mock('./clientId', () => ({
|
|
getOrCreateClientId: () => 'test-client'
|
|
}));
|
|
|
|
import { useEventsDispatcher } from './events.svelte';
|
|
import {
|
|
player,
|
|
playQueue,
|
|
reportStateFromAudio,
|
|
skipNext,
|
|
} from './store.svelte';
|
|
import { api } from '$lib/api/client';
|
|
import type { TrackRef } from '$lib/api/types';
|
|
|
|
function track(id: string, dur = 200): TrackRef {
|
|
return {
|
|
id, title: `T ${id}`,
|
|
album_id: 'a', album_title: 'A',
|
|
artist_id: 'ar', artist_name: 'Ar',
|
|
track_number: Number(id), disc_number: 1,
|
|
duration_sec: dur,
|
|
stream_url: `/api/tracks/${id}/stream`
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset the store to idle.
|
|
playQueue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
// sendBeacon stub cleanup
|
|
// (vi.spyOn restored automatically on test boundary)
|
|
});
|
|
|
|
describe('useEventsDispatcher', () => {
|
|
test('first transition into playing for a new track POSTs play_started', async () => {
|
|
let cleanup!: () => void;
|
|
cleanup = $effect.root(() => useEventsDispatcher());
|
|
|
|
playQueue([track('1')]);
|
|
flushSync();
|
|
reportStateFromAudio('playing');
|
|
flushSync();
|
|
// Microtasks for the mocked api.post to settle.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(api.post).toHaveBeenCalledTimes(1);
|
|
expect(api.post).toHaveBeenCalledWith('/api/events', {
|
|
type: 'play_started',
|
|
track_id: '1',
|
|
client_id: 'test-client'
|
|
});
|
|
|
|
cleanup();
|
|
});
|
|
|
|
test('track end (back to idle/paused) POSTs play_ended', async () => {
|
|
let cleanup!: () => void;
|
|
cleanup = $effect.root(() => useEventsDispatcher());
|
|
|
|
playQueue([track('1', 200)]);
|
|
flushSync();
|
|
reportStateFromAudio('playing');
|
|
flushSync();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
// Now end the track.
|
|
reportStateFromAudio('ended');
|
|
flushSync();
|
|
await Promise.resolve();
|
|
|
|
const calls = (api.post as ReturnType<typeof vi.fn>).mock.calls;
|
|
const types = calls.map((c) => (c[1] as { type: string }).type);
|
|
expect(types).toContain('play_started');
|
|
expect(types).toContain('play_ended');
|
|
|
|
cleanup();
|
|
});
|
|
|
|
test('user-initiated skip mid-track POSTs play_skipped', async () => {
|
|
let cleanup!: () => void;
|
|
cleanup = $effect.root(() => useEventsDispatcher());
|
|
|
|
playQueue([track('1'), track('2')]);
|
|
flushSync();
|
|
reportStateFromAudio('playing');
|
|
flushSync();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
skipNext();
|
|
flushSync();
|
|
await Promise.resolve();
|
|
|
|
const calls = (api.post as ReturnType<typeof vi.fn>).mock.calls;
|
|
const types = calls.map((c) => (c[1] as { type: string }).type);
|
|
expect(types).toContain('play_skipped');
|
|
|
|
cleanup();
|
|
});
|
|
|
|
test('pagehide fires sendBeacon when a play is open', async () => {
|
|
let cleanup!: () => void;
|
|
cleanup = $effect.root(() => useEventsDispatcher());
|
|
const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
|
|
|
|
playQueue([track('1')]);
|
|
flushSync();
|
|
reportStateFromAudio('playing');
|
|
flushSync();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
window.dispatchEvent(new Event('pagehide'));
|
|
|
|
expect(beacon).toHaveBeenCalled();
|
|
const args = beacon.mock.calls[0];
|
|
expect(args[0]).toBe('/api/events');
|
|
|
|
cleanup();
|
|
beacon.mockRestore();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 7: Run, confirm fail**
|
|
|
|
Run: `cd web && npm test -- src/lib/player/events.svelte.test.ts`
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 8: Implement `web/src/lib/player/events.svelte.ts`**
|
|
|
|
```ts
|
|
import { player } from './store.svelte';
|
|
import { api } from '$lib/api/client';
|
|
import { getOrCreateClientId } from './clientId';
|
|
import type { PlayStartedResponse } from '$lib/api/types';
|
|
|
|
// useEventsDispatcher installs $effect-based watchers that emit play events
|
|
// to /api/events. Call once from +layout.svelte's mount.
|
|
//
|
|
// The dispatcher is intentionally a simple state machine over (current track,
|
|
// state). It does NOT introspect player internals beyond the rune accessors;
|
|
// it only reacts to externally-observable transitions.
|
|
export function useEventsDispatcher(): void {
|
|
let openPlayEventId: string | null = null;
|
|
let openTrackId: string | null = null;
|
|
let lastPositionMs = 0;
|
|
const clientId = getOrCreateClientId();
|
|
|
|
// Track the "active" track id we've already started a play for. Each
|
|
// transition into 'playing' for a new track triggers play_started; each
|
|
// transition out of 'playing' or change-of-track closes the prior row.
|
|
$effect(() => {
|
|
const track = player.current;
|
|
const isPlaying = player.state === 'playing';
|
|
|
|
if (track && isPlaying && openTrackId !== track.id) {
|
|
// New track entered playing state — close any prior open one (skip
|
|
// semantics for user-driven track changes), then open this one.
|
|
if (openPlayEventId && openTrackId) {
|
|
const prevId = openPlayEventId;
|
|
const prevPos = lastPositionMs;
|
|
openPlayEventId = null;
|
|
openTrackId = null;
|
|
api.post<{ ok: true }>('/api/events', {
|
|
type: 'play_skipped',
|
|
play_event_id: prevId,
|
|
position_ms: prevPos
|
|
}).catch(() => { /* drop — server-side is best-effort */ });
|
|
}
|
|
void startNew(track.id);
|
|
} else if (!track && openPlayEventId) {
|
|
// Queue cleared (idle) — close out as a skip.
|
|
const prevId = openPlayEventId;
|
|
const prevPos = lastPositionMs;
|
|
openPlayEventId = null;
|
|
openTrackId = null;
|
|
api.post('/api/events', {
|
|
type: 'play_skipped',
|
|
play_event_id: prevId,
|
|
position_ms: prevPos
|
|
}).catch(() => {});
|
|
}
|
|
});
|
|
|
|
// Track played duration via player.position. We sample on every update;
|
|
// the cost is one int copy.
|
|
$effect(() => {
|
|
lastPositionMs = Math.round(player.position * 1000);
|
|
});
|
|
|
|
// 'ended' transition (state moves out of playing AFTER a finished track)
|
|
// triggers a normal play_ended close, distinguished from skip-by-track-change.
|
|
$effect(() => {
|
|
const isFinished = player.state === 'paused' && player.position >= player.duration && player.duration > 0;
|
|
if (isFinished && openPlayEventId) {
|
|
const prevId = openPlayEventId;
|
|
const dur = lastPositionMs;
|
|
openPlayEventId = null;
|
|
openTrackId = null;
|
|
api.post('/api/events', {
|
|
type: 'play_ended',
|
|
play_event_id: prevId,
|
|
duration_played_ms: dur
|
|
}).catch(() => {});
|
|
}
|
|
});
|
|
|
|
// Page close: sendBeacon a play_skipped if an open play exists.
|
|
if (typeof window !== 'undefined') {
|
|
const onPageHide = () => {
|
|
if (!openPlayEventId) return;
|
|
const payload = JSON.stringify({
|
|
type: 'play_skipped',
|
|
play_event_id: openPlayEventId,
|
|
position_ms: lastPositionMs
|
|
});
|
|
navigator.sendBeacon('/api/events', new Blob([payload], { type: 'application/json' }));
|
|
};
|
|
window.addEventListener('pagehide', onPageHide);
|
|
// No teardown — useEventsDispatcher is mounted for the app lifetime.
|
|
}
|
|
|
|
async function startNew(trackId: string): Promise<void> {
|
|
try {
|
|
const res = await api.post<PlayStartedResponse>('/api/events', {
|
|
type: 'play_started',
|
|
track_id: trackId,
|
|
client_id: clientId
|
|
});
|
|
// The user may have already moved on (clicked another track) by the
|
|
// time the response arrives. Only adopt the id if we're still on the
|
|
// same track and still playing.
|
|
if (player.current?.id === trackId && player.state === 'playing') {
|
|
openPlayEventId = res.play_event_id;
|
|
openTrackId = trackId;
|
|
}
|
|
} catch {
|
|
// Best-effort; missed events are OK in v1.
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 9: Run, confirm pass**
|
|
|
|
Run: `cd web && npm test -- src/lib/player/events.svelte.test.ts`
|
|
Expected: PASS — 4 tests.
|
|
|
|
- [ ] **Step 10: Type-check**
|
|
|
|
Run: `cd web && npm run check`
|
|
Expected: `0 ERRORS 0 WARNINGS`.
|
|
|
|
- [ ] **Step 11: Commit**
|
|
|
|
```bash
|
|
git add web/src/lib/player/clientId.ts web/src/lib/player/clientId.test.ts \
|
|
web/src/lib/player/events.svelte.ts web/src/lib/player/events.svelte.test.ts \
|
|
web/src/lib/api/types.ts
|
|
git commit -m "feat(web): events dispatcher posts to /api/events on player transitions
|
|
|
|
useEventsDispatcher subscribes to player.current/state via \$effect:
|
|
on first transition to playing for a new track, POSTs play_started
|
|
and caches the id; on track change while playing, closes the prior
|
|
row as play_skipped; on natural completion (paused at duration), POSTs
|
|
play_ended; on pagehide, navigator.sendBeacon fires a final
|
|
play_skipped. Stable per-tab client_id from sessionStorage."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Wire `useEventsDispatcher()` in `+layout.svelte`
|
|
|
|
**Files:**
|
|
- Modify: `web/src/routes/+layout.svelte`
|
|
|
|
- [ ] **Step 1: Add the import**
|
|
|
|
Open `web/src/routes/+layout.svelte`. Find the existing `useMediaSession` import and add a sibling:
|
|
|
|
```ts
|
|
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
|
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
|
```
|
|
|
|
- [ ] **Step 2: Call it once at mount**
|
|
|
|
Find the `useMediaSession();` call near the bottom of the script block. Add right after:
|
|
|
|
```ts
|
|
useMediaSession();
|
|
useEventsDispatcher();
|
|
```
|
|
|
|
- [ ] **Step 3: Type-check**
|
|
|
|
Run: `cd web && npm run check`
|
|
Expected: `0 ERRORS 0 WARNINGS`.
|
|
|
|
- [ ] **Step 4: Run full web tests**
|
|
|
|
Run: `cd web && npm test`
|
|
Expected: all suites PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add web/src/routes/+layout.svelte
|
|
git commit -m "feat(web): mount useEventsDispatcher() in root layout
|
|
|
|
Single mount point alongside useMediaSession. Once called, the
|
|
dispatcher's \$effect watchers run for the lifetime of the SPA."
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Final verification + branch finish
|
|
|
|
**Files:** none (verification only).
|
|
|
|
- [ ] **Step 1: Full Go suite (with DB)**
|
|
|
|
Run:
|
|
```bash
|
|
MINSTREL_TEST_DATABASE_URL='postgres://minstrel:minstrel@172.22.0.2:5432/minstrel?sslmode=disable' \
|
|
go test -race ./...
|
|
```
|
|
Expected: all packages PASS (including the integration tests).
|
|
|
|
- [ ] **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-events-smoke .`
|
|
Expected: image builds.
|
|
|
|
Run: `docker run --rm --entrypoint /bin/sh minstrel:m2-events-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'`
|
|
Expected: `ok`.
|
|
|
|
- [ ] **Step 5: Local end-to-end manual check**
|
|
|
|
```bash
|
|
docker compose up --build -d
|
|
```
|
|
|
|
Browser at `localhost:4533`:
|
|
|
|
1. Sign in. Play a track.
|
|
2. Confirm in postgres:
|
|
```bash
|
|
docker exec minstrel-postgres-1 psql -U minstrel -d minstrel \
|
|
-c "SELECT user_id, track_id, started_at, ended_at FROM play_events;"
|
|
```
|
|
Expected: one row, `ended_at` NULL.
|
|
3. Let track finish. Re-run the SELECT. Expected: same row now has `ended_at` set, `was_skipped=false`.
|
|
4. Skip a track within 30 seconds of starting. Expected: previous row marked `was_skipped=true`; new row exists for the next track; `skip_events` has the matching row.
|
|
5. Wait > 30 minutes (or use `psql` to manually `UPDATE play_sessions SET last_event_at = now() - INTERVAL '40 minutes' WHERE user_id = ...` to fast-forward). Play. Expected: new `play_sessions` row with a different id.
|
|
6. Open Feishin/Symfonium pointed at the same instance. Play a track to completion. Expected: a synthetic `play_events` row appears with both `started_at` and `ended_at` set.
|
|
7. Close the browser tab mid-track. Re-run the SELECT. Expected: the last row's `ended_at` is set (sendBeacon hit the server before tab actually closed).
|
|
8. Disconnect network mid-play, force-quit the browser. Reconnect, sign back in, start a new track. Expected: the previous abandoned row gets auto-closed by the events handler before the new row is inserted.
|
|
|
|
Tear down:
|
|
```bash
|
|
docker compose down
|
|
```
|
|
|
|
- [ ] **Step 6: Finish the branch**
|
|
|
|
Follow `superpowers:finishing-a-development-branch`. Expected path: Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green.
|
|
|
|
---
|
|
|
|
## Self-Review Notes
|
|
|
|
**Spec coverage:**
|
|
- Schema (play_sessions, play_events, skip_events) → Task 1.
|
|
- Config thresholds → Task 2.
|
|
- Session service `FindOrCreate` with 30-min rolling rule + boundary case → Task 3.
|
|
- playevents writer + auto-close-prior + skip rule + synthetic completed play → Task 4.
|
|
- `POST /api/events` handler with discriminated union + ownership checks → Task 5.
|
|
- Subsonic `/rest/scrobble` integration + `nowPlayingMap` removal → Task 6.
|
|
- Web events module + clientId + sendBeacon → Tasks 7, 8.
|
|
- End-to-end manual checklist matches spec's verification list → Task 9.
|
|
|
|
**Type consistency:**
|
|
- `pgtype.UUID` everywhere on the Go side; never `string` for UUIDs except at HTTP boundaries.
|
|
- `Writer` (capital W) used consistently as the playevents type.
|
|
- `RecordPlayStarted` / `RecordPlayEnded` / `RecordPlaySkipped` / `RecordSyntheticCompletedPlay` — same names in the spec, plan, and writer module.
|
|
- `EventRequest` / `PlayStartedResponse` / `EventOkResponse` — same TypeScript names in types.ts and consumers.
|
|
- `useEventsDispatcher()` — same hook name in module + +layout.svelte call site.
|
|
- `getOrCreateClientId` — same name in module + events module + tests.
|
|
|
|
**Filename hazards:** events.svelte.ts uses runes ($effect); the test file is events.svelte.test.ts because the test body uses `$effect.root(...)` directly (matching the useDelayed.svelte.test.ts and mediaSession.svelte.test.ts precedents).
|
|
|
|
**Placeholder scan:** no TBD/TODO/later markers. Every code block is a complete implementation; every command shows expected output.
|
|
|
|
**Risks / mitigations from spec:**
|
|
- Event spam → bounded by auto-close-prior (Task 4 implementation).
|
|
- Clock skew → web client never sends `at` (Task 7 implementation just doesn't include the field); Subsonic clients honor `time` parameter (Task 6 handler parses both ms-since-epoch and RFC 3339).
|
|
- Synthetic plays too coarse → spec acknowledges; documented in writer comment (Task 4).
|
|
- `nowPlayingMap` deletion → no external callers; safe (verified during spec).
|
|
- Nullable `session_vector_at_play` → ships with the migration; M2 writes NULL (writer never sets it); M3 will populate.
|