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.
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.
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.
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.
Validates seed_track + optional limit (default cfg.Recommendation.RadioSize,
clamped to RadioSizeMax). Calls recommendation.LoadCandidates +
recommendation.Shuffle. Prepends seed to result. Server seeds a
math/rand source at startup; handlers package threads that as a
func() float64 so tests inject deterministic RNGs.
Mount + server.New gain a RecommendationConfig parameter.
Single SELECT that joins tracks with general_likes (for is_liked) and
an aggregated LATERAL subquery on play_events (for last_played_at,
play_count, skip_count). Excludes seed + tracks played in the last
N hours. Drives the M3 weighted shuffle scoring.
Composes Score over a candidate slice, sorts descending, truncates.
Pure — no IO. Liked-rank-higher and high-skip-ranks-last behaviors
are stable across RNG seeds (jitter band is smaller than LikeBoost
and SkipPenalty).
Implements spec §6 weighted-shuffle scoring without the
contextual_match_score term (sub-plan #3 adds it). Pure Go, no DB
dependency; injectable RNG for deterministic tests. Coverage 100%
on score.go via the boundary tests.
Both return user's starred artists/albums/songs sorted liked_at DESC.
Cap at 500 entries per category for v1 (Subsonic spec doesn't define
pagination on these endpoints; M3+ can revisit if needed).
Validate-all-first atomicity on star: a missing entity refuses the whole
call with Subsonic error 70. Unstar is a best-effort delete (missing
entities are no-ops, matching client expectations after library moves).
Like/unlike are POST/DELETE on /api/likes/{type}/{id}; idempotent (204
on repeats). List endpoints return Page<TrackRef|AlbumRef|ArtistRef>
sorted liked_at DESC. /api/likes/ids returns flat id arrays for the
client-side heart-button cache.
Three tables keyed on (user_id, entity_id) with liked_at. Per-table
indexes on (user_id, liked_at DESC) for the recently-liked feed.
sqlc queries cover like/unlike/list-rows/count/list-ids per entity.
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.
api.Mount now takes the shared *playevents.Writer instead of cfg so
the same writer instance feeds both surfaces.
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. Mount signature now takes EventsConfig and constructs
the playevents.Writer; server.New propagates it through.
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.
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.
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.
M6 stub. Validates seed_track param (400 on missing/blank/bad UUID),
looks up the track (404 on miss), returns RadioResponse with a single
TrackRef. M4 will replace the body with similarity-driven candidate
pool + scoring; the request/response shape is final.
- NotFound wrapper routes unknown /api/* and /rest/* to a JSON 404
- Everything else falls through to the embedded web handler, which
resolves static assets or returns index.html for SPA deep links
go:embed paths are relative to the source file and cannot reach parent
directories, so internal/web/embed.go could not see web/build/ without
duplicating the placeholder. Relocating to web/embed.go lets the
directive resolve to the real SvelteKit build output with no copy step.
CI's golangci-lint run flagged three files; two pre-existed this
branch but the Plan 3 seedTrackWithFile struct-literal alignment
is new. Applying gofmt across all three keeps the lint baseline clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sessionTokenFromHTTP now matches extractBearerToken's trimming
behavior. Without this, a bearer header with trailing whitespace
(e.g. a buggy client or proxy) authenticates via RequireUser but
hashes the padded value on logout, silently no-oping and leaving
the server-side session alive.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the native JSON surface alongside the existing /api/admin
and /rest/* routes. Subsonic compatibility remains untouched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Returns the authenticated user (id/username/is_admin). Shape
matches UserView used in the login response so SPA stores stay
typed against one interface.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Deletes the session row keyed by the cookie/bearer token and
clears the cookie on the client. Best-effort DB delete — logout
still succeeds for the client if the row's already gone.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Verifies bcrypt password hash, mints a session token, stores its
sha256 in the sessions table, and returns the token in both the
response body (for Flutter) and an httpOnly/SameSite=Strict
cookie (for the web SPA).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces internal/api with Mount(), the {error:{code,message}}
response shape, and stubbed login/logout/me so later tasks can
land one handler at a time without breaking the build.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolves /api/* callers from session cookie first, Authorization
bearer second. Touches last_seen on success for future active-
sessions UI. Adds GetUserByID query used by the middleware.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>