Third and final M4 sub-plan (Fable #347) — closes M4. Replaces M3's
whole-library candidate pool with a 5-way SQL UNION (LB-similar tracks /
tracks by similar artists / MB-tag overlap / likes-overlap / random
fill). Adds a new SimilarityScore × SimilarityWeight term to M3's Score()
formula. Web client auto-refreshes the queue when 80% consumed via a new
?exclude= query param. No lazy LB fetch (M4b's worker + random
augmentation handle sparse data). Fallback to M3 LoadCandidates on any
similarity-pool error.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace stub with full tickOnce: drains played tracks/artists via
ListPlayedTracksNeedingSimilarity / ListPlayedArtistsNeedingSimilarity,
calls LB SimilarRecordings/SimilarArtists, filters to local library via
bulk MBID lookup, enforces top-K=20 by score, and upserts similarity
rows. 429 aborts the entire tick; other errors log-and-skip. Ten
integration tests covering no-op, library filtering, top-K cap, 7-day
freshness cap, stale refresh, 429 abort, transient skip, and artist
pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Second M4 sub-plan (Fable #346). Periodic worker pulls track-track and
artist-artist similarity edges from LB's public /explore/* endpoints,
filters to the local library, stores top-20 per source track in two new
tables (track_similarity, artist_similarity). Hourly tick, batch=5,
weekly re-fetch cap per row, passive retry via timer. No auth (public
endpoints). Discovery within library handled by LB's collaborative-
filtering response naturally surfacing unplayed library tracks; spec
notes M4c will add a serendipity floor + lazy fetch + sparse-fallback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds api.put helper, ListenBrainz API module with query/mutation
helpers, a /settings route with token + enable/disable UI, and 5
tests. Adds Settings to the Shell nav.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exposes user ListenBrainz config (token_set bool, enabled, last_scrobbled_at)
via write-only token semantics; enforces no-enable-without-token at the API layer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bridges closed play_events to the scrobble_queue: loads the event,
applies the Qualifies() threshold, checks user LB config (enabled + token),
and inserts via ON CONFLICT DO NOTHING for idempotency. Best-effort —
callers (playevents.Writer hooks, Task 8) will log and swallow errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the HTTP client for ListenBrainz submit-listens with typed
sentinel errors (ErrAuth, ErrPermanent, ErrTransient, *RetryAfterError)
so the scrobble worker can branch on response semantics. All 9 httptest-
driven tests pass.
First M4 sub-plan (Fable #345). Outbound scrobble worker with batching +
exponential-backoff retry. Per-user token, plaintext storage, pull-based
30s timer worker, patient backoff (1m → 5m → 30m → 2h → 6h, 5 attempts).
New scrobble_queue table; sent rows deleted (canonical record stays in
play_events.scrobbled_at). Minimal /settings page in M4a as scaffold for
M6's full Settings sub-plan to extend.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Forgejo Actions fired both `push` and `pull_request` events for the same
commit when pushing to dev with an open PR — doubled CI runs on every PR
commit (e.g. #182 and #183 on PR #24's HEAD `95d68e3`). Drop the push
trigger entirely; PRs against main are the only quality gate that
matters in this workflow (dev → PR → main, never direct push to main).
Trade-off: direct pushes to dev with no open PR no longer get CI on
their own. Acceptable — the moment the PR opens, the pull_request event
fires and runs the same workflow.
Two carry-overs from M3 verification, bundled with the search-input
fix already on dev (b7a59a9).
1. Genre splitting in BuildSessionVector
The library has many tracks whose genre tag is a denormalized
multi-genre string ("Indie Pop; Pop; Alternative Pop"). Reading them
as one opaque tag means a single-genre "Pop" track and a multi-genre
track listing Pop both fail to share the Pop key, so the tags axis
in similarity scoring (weight 0.7!) returned 0 in nearly all cases
on real libraries. Result: contextual scoring couldn't differentiate.
Add splitGenres() that splits on ; and , and trims whitespace;
strings without a delimiter come back as a single-element slice
(so the existing single-genre cases keep working unchanged).
Concatenated-without-separator output ("ElectronicComplextroGlitch
Hop") stays opaque — that needs a genre dictionary, out of scope.
2. Play-radio button on TrackRow
playRadio() was only wired to the click handler on /search and
/search/tracks, leaving /library/liked, album pages, and search
results' inner clicks with no way to start a radio. Adds a small
radio button to TrackRow (between LikeButton and the +queue button).
Click → playRadio(track.id), with stopPropagation so the row's own
activate handler doesn't also fire.
Tests:
- 3 new sessionvector tests: TestSplitGenres, multi-genre semicolon
split, no-separator stays opaque. Existing single-genre tests pass
unchanged.
- 1 new TrackRow test: radio button calls playRadio with track id and
does NOT trigger row play.
Verified locally: go -short -race ./... clean, golangci-lint clean,
svelte-check 0/0, 175 vitest tests (was 174), web build succeeds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The actual cause of the search-field focus loss: SvelteKit's `goto()`
defaults to resetting focus on navigation, so each debounced URL update
blurred the input mid-typing. Pass `keepFocus: true` to both `goto()`
calls in `navigate()`.
The earlier `bind:value` change in 6931358 didn't address this — it was
solving a hypothetical DOM-write-side cursor-jump issue rather than the
real focus-reset behavior. Leaving `bind:value` in place since it's
still cleaner Svelte 5.
Tests updated to match the new goto args.
The previous one-way `{value}` + manual `oninput` rewrite let Svelte
re-set the input's DOM value whenever navigation completed, which moved
the cursor (and on some browsers, blurred the input) mid-typing. Switch
to Svelte 5's `bind:value` — it skips DOM writes when the JS value
already matches the DOM, so the user's typing isn't disturbed.
The URL-resync `\$effect` is unchanged: it still updates `value` for
back/forward navigation but is a no-op during normal typing because the
debounce already wrote the matching URL.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CI's golangci-lint (`gofmt -s`) on Go 1.23 flagged inline comments after
the SessionVector literals in TestContextualMatchScore_TakesMax because
the variable-length values produced misaligned trailing columns. Local
gofmt on Go 1.26 was lenient. Replace with a leading comment that covers
all three rows; same intent, no alignment dance.
Wire contextual scoring end-to-end: add ContextWeight to RecommendationConfig
(struct + Default()), fetch the user's current session vector in handleRadio
via loadCurrentSessionVector (cold-start returns Seed sentinel), and pass it
as the 6th arg to LoadCandidates so ContextualMatchScore flows into Shuffle.
Code review caught four tests using `got, _` to discard load errors. A DB
failure would have surfaced as a misleading "wrong score" assertion rather
than a clear "load failed" message. Match the t.Fatalf pattern used by the
other LoadCandidates tests in this file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a 6th argument (currentVector SessionVector) to LoadCandidates,
bulk-fetches the user's active contextual_likes in one query, and sets
ContextualMatchScore on each candidate's ScoringInputs via max-similarity
over that candidate's like vectors. Adds helper + 6 integration tests
covering no-likes, single match, multi-like max, soft-delete filtering,
seed-like filtering, and seed-current short-circuit. Adds contextual_likes
to the TRUNCATE list in testPool to prevent cross-test leakage.
internal/api/radio.go is intentionally left broken (Task 6 fixes it).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eight-task TDD-shaped plan covering pure Similarity + ContextualMatchScore
functions, Score extension with ContextWeight=2.0, two new sqlc queries
(ListActiveContextualLikesForUser, GetCurrentSessionVectorForUser),
LoadCandidates signature change to accept currentVector, radio handler
wiring, end-to-end contextual ranking test, and final verification.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes M3 — adds the `contextual_match_score` term to the scoring formula
via weighted-Jaccard similarity (tags 0.7, artists 0.3) over the user's
contextual_likes. Reads the current session vector from the most recent
open play_event (populated by sub-plan #2). Cold-start paths collapse to
zero contribution, preserving v1 behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.
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.
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.
Sub-plan #2 of 3 in M3 (Fable #341). Two write-path additions on top
of M2's events + likes infra:
1. play_started: compute session vector from prior 5 tracks in
current play_session, write to play_events.session_vector_at_play.
2. like a track: if open play_event has populated vector, snapshot
into contextual_likes with vector + session_id. Soft-delete on
unlike (deleted_at column gates engine queries).
Includes new migration 0007_contextual_likes — the table was missing
from migration 0005 despite being mentioned in its comments. Adds
GIN index for sub-plan #3's similarity queries.
Backend-only slice; no UI changes.
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.
Replaces the M2 stub /api/radio with real weighted-shuffle scoring:
LikeBoost + recency_decay - skip_ratio penalty + jitter, hard
suppression of last-hour plays. Wide candidate pool (whole library)
for v1; M4 adds similarity-based pool refinement. Pure scoring
function in internal/recommendation; live-DB candidate loader; HTTP
handler is a thin shim. No web changes — existing playRadio action
already calls /api/radio.
Sub-plan #1 of 3 in M3 (Fable #340). Session vectors and contextual
match score land in the next two sub-plans.
Pages that render TrackRow/AlbumCard/ArtistRow/PlayerBar now indirectly
mount LikeButton, which calls useQueryClient(). Route tests need the
same mocks the component-level tests have.
- Added createLikedIdsQuery mock to 7 route test files
- Added useQueryClient mock to all affected route tests
- Added readable store import where needed
Each section is its own infinite query against /api/likes/{type}.
Empty sections show 'No liked X yet' (gated on total === 0). Reuses
ArtistRow / AlbumCard / TrackRow for rendering — same components as
the search overflow pages.
Heart appears on every entity rendering surface. ArtistRow restructured
to a <div> with the link as a positioned overlay so the heart button
can nest without invalid <button>-in-<a> markup. Shell nav grows a
'Liked' entry pointing at /library/liked.
Heart icon reading from createLikedIdsQuery; click toggles via
likeEntity/unlikeEntity. Click stops propagation so it can nest
inside TrackRow's role=button div without triggering row activation.
createLikedIdsQuery is the single source for heart-button state across
the app (track_ids/album_ids/artist_ids Sets). likeEntity / unlikeEntity
do optimistic setQueryData updates with rollback on error. Per-entity
infinite queries back the /library/liked sections.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.
Three new tables (general_likes, general_likes_albums,
general_likes_artists), seven native /api/likes/* endpoints, four
Subsonic handlers (star/unstar/getStarred/getStarred2), and web UI
heart buttons on TrackRow/AlbumCard/ArtistRow/PlayerBar plus a new
/library/liked page. Closes M2 (with the events foundation already
in main).
Liked state is computed client-side via O(1) Set lookups against a
single createLikedIdsQuery cache; server stays simple per request
(no joins added to entity reads). Optimistic updates with rollback.
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-id change while a play is open, closes
the prior row as play_skipped; on natural completion (state
playing→paused with position >= duration > 0), POSTs play_ended;
on pagehide, navigator.sendBeacon fires a final play_skipped.
Stable per-tab client_id from sessionStorage.
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.
Specifies POST /api/events with start+end semantics, session service
(30-min rolling), play/skip classification (spec §6 boundaries),
sendBeacon + auto-close-on-next-start for abandoned plays, and
Subsonic /rest/scrobble integration that writes synthetic completed
plays so third-party clients feed the recommendation pipeline.
Schema migration ships the full M2 set (play_events, skip_events,
sessions) including M3-only nullable columns so M3 doesn't need a
follow-up migration. General likes deferred to a separate M2
sub-plan; this slice covers spec §13 step 5 only.
/search/artists, /search/albums, /search/tracks each read ?q= and run
their own createSearchInfiniteQuery (limit=50). Load more pulls the
next offset; back link returns to /search?q=. Empty q shows a 'no
query' prompt and fires no request. Tracks overflow uses the onPlay
prop on TrackRow to call playRadio(track.id).
Reads ?q= reactively; runs createSearchQuery (limit=10) when q is
non-empty. Renders Artists/Albums/Tracks sections reusing ArtistRow,
AlbumCard, TrackRow. Track click uses onPlay prop to call
playRadio(track.id) instead of the default playQueue. Empty facets
hide; 'See all N →' link points to overflow pages when total > items.
Empty q shows 'Start typing'; error → ApiErrorBanner; loading →
SearchSkeleton via useDelayed.
Header grows a flex-1 column between the brand and the user menu.
Existing Shell tests remain green (the search input has no impact on
nav/menu behavior).
Pre-fills from page.url.searchParams.get('q'); typing debounces 250ms
then goto('/search?q=…'); first nav from elsewhere pushes, subsequent
typing on /search replaces. Empty input on /search drops the param.
Escape clears the input. URL → value re-sync via \$effect uses
untrack() on the value comparison so it doesn't loop with typing.
Wraps the existing link in a relative container; adds an absolutely
positioned + button. On click stops propagation, fetches /api/albums/:id
once, and feeds tracks into enqueueTracks.
Outer <button> becomes <div role="button" tabindex="0"> with keyboard
handlers (Enter/Space) so a real <button> for + queue can nest without
invalid HTML. New optional onPlay prop overrides the default
playQueue(tracks, index); used by the search Tracks section to call
playRadio(track.id) instead. + queue button calls enqueueTrack and
stops propagation so the row click does not fire.
createSearchQuery hits /api/search with limit=10 (the main /search
page summary). createSearchArtistsInfiniteQuery /
createSearchAlbumsInfiniteQuery / createSearchTracksInfiniteQuery each
hit /api/search with limit=50 and project to their own facet, used by
the per-facet overflow pages. All four are gated by q.length > 0 so
empty queries don't fire.
enqueueTrack/enqueueTracks append to the queue without disturbing
playback state (no auto-play on append, no state mutation when queue
was non-empty). playRadio fetches /api/radio?seed_track=<id> and
feeds the response into playQueue. Empty response leaves queue alone.
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.
Specifies header search bar (debounced, URL-synced ?q=), /search page
with three sections of top-10 reusing library components, three
overflow routes (/search/artists, /search/albums, /search/tracks)
backed by createInfiniteQuery. Track click triggers playRadio with
seed via a new /api/radio stub that returns the seed alone (M4 fills
in real similarity-based selection later, response shape final). Adds
+queue button on TrackRow and AlbumCard via new enqueueTrack /
enqueueTracks player-store actions.
The layout's play-intent effect gated audioEl.play() on player.isPlaying
(state === 'playing'), but the state progression is loading → DOM play()
→ 'playing' event → state='playing'. Without calling play() during
loading, the DOM never starts, the event never fires, and state is
pinned to loading forever. Call play() for the intent-to-play union
(loading + playing), matching the store's design.
Single hidden <audio> survives navigation. Effects drive it from the
store (src, volume, play/pause) and DOM events report back (timeupdate,
loadedmetadata, playing, pause, waiting, ended, error). useMediaSession
is called once here so OS media controls light up as soon as the user
signs in.
Grid grows to 3 rows (auto / 1fr / auto); PlayerBar spans both columns
in the last row, rendering only when player.current is defined so the
row collapses before first play.
TrackRow becomes a <button> that dispatches playQueue(tracks, index).
Album page supplies the full track list + each index so the rest of
the album enqueues automatically when the user clicks a track.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
useMediaSession() wires navigator.mediaSession metadata + action
handlers (play/pause/previous/next/seekto) + positionState to the
player store via $effect. Exports nothing stateful — callers just
invoke it once at root-layout mount.
toggleShuffle (Fisher-Yates over queue[index+1..end] with injectable
RNG for test determinism), cycleRepeat (off → all → one → off),
reportStateFromAudio with full repeat-mode semantics on 'ended',
plus simple state mirroring for playing/paused/waiting/error. Extends
skipNext to wrap when repeat='all'.
Core state (queue, index, state, position, duration, volume, shuffle,
repeat, error) plus actions: playQueue, togglePlay, linear skipNext,
skipPrev 3-second rule, seekTo, setVolume (localStorage-persisted),
registerAudioEl, reportTimeUpdate, reportDuration. Shuffle/repeat
logic and audio-event reporting land in follow-up commits.
Also install an in-memory Storage polyfill in vitest.setup.ts: Node 25
exposes a partial localStorage global that lacks getItem/setItem/clear
(requires --localstorage-file with a path), and jsdom defers to the
Node global when present, leaving tests unable to exercise storage.