Commit Graph

273 Commits

Author SHA1 Message Date
bvandeusen 5b79aa1047 feat(recommendation): LoadCandidates computes per-candidate ContextualMatchScore
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>
2026-04-27 20:41:55 -04:00
bvandeusen cb46b3830f feat(db): add similarity lookup queries (ListActiveContextualLikesForUser, GetCurrentSessionVectorForUser) 2026-04-27 20:36:42 -04:00
bvandeusen 49871ba06d feat(recommendation): extend Score with ContextualMatchScore + ContextWeight 2026-04-27 20:31:54 -04:00
bvandeusen 347884bc2b feat(recommendation): add ContextualMatchScore (max over non-seed likes) 2026-04-27 20:20:53 -04:00
bvandeusen a7211aacff feat(recommendation): add pure Similarity function with weighted Jaccard 2026-04-27 20:04:35 -04:00
bvandeusen 00483539ad docs(plan): add M3 session similarity implementation plan
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>
2026-04-27 19:57:18 -04:00
bvandeusen 20963847c2 docs(spec): add M3 session similarity + contextual_match_score design
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>
2026-04-27 19:57:10 -04:00
bvandeusen bf7c5ad47d feat(subsonic): wire contextual_likes capture/soft-delete into star/unstar
handleStar's track branch calls playevents.CaptureContextualLikeIfPlaying
when the underlying LikeTrack actually inserted a row. handleUnstar
calls SoftDeleteContextualLikes after every track-id unstar. Same
helpers as the api surface — single source of truth.

mediaHandlers struct gains a logger field threaded through Mount.
2026-04-27 11:25:10 -04:00
bvandeusen fe1cc46752 feat(api): capture contextual_likes on like during open play_event
Two new helpers in internal/playevents (CaptureContextualLikeIfPlaying,
SoftDeleteContextualLikes) called by both api and subsonic surfaces.
Like inserts a new contextual_likes row when LikeTrack actually
inserted (rows=1) AND there's an open play_event with a vector.
Unlike soft-deletes via deleted_at. Idempotent in both directions.

Subsonic wiring lands in the next commit.
2026-04-27 11:22:35 -04:00
bvandeusen d6acb3b63c feat(playevents): persist session_vector_at_play on every record path
RecordPlayStarted and RecordSyntheticCompletedPlay both capture the
session vector inside their existing transactions. Single private
helper queries the prior 5 tracks, builds the vector, UPDATEs the
just-inserted play_event. Tests verify the seed flag boundary and
session-scope isolation.
2026-04-27 11:19:16 -04:00
bvandeusen 627b7d1be5 feat(recommendation): add BuildSessionVector pure function
Pure aggregation per spec §6: Seed flag (true when prior < 3),
deduplicated Artists ordered by first appearance, Tags bag-of-counts
from tracks.genre (empty genres skipped), RecentTrackIDs preserving
input order. JSON round-trip verified.
2026-04-27 11:17:11 -04:00
bvandeusen d43d8df6d5 feat(db): add session-vector capture queries; LikeTrack returns row count
ListRecentSessionTracks + UpdatePlayEventVector for the vector capture
path inside RecordPlayStarted. LikeTrack switches to :execrows so the
contextual-likes capture can detect insert vs already-exists. Existing
callers updated to ignore the count for now; later tasks consume it.
2026-04-27 11:16:05 -04:00
bvandeusen 92ef53c04d feat(db): add contextual_likes table (M3 session-context capture)
Table was referenced in migration 0005's comment but never created —
this slice fills the gap. Soft-delete via deleted_at column; hot-path
partial index on active rows; GIN index on session_vector for
M3 sub-plan #3's similarity queries.
2026-04-27 11:13:31 -04:00
bvandeusen 38beadf22e docs(plan): add M3 session vectors + contextual likes implementation plan
7 tasks: contextual_likes migration + sqlc, events.sql vector queries +
LikeTrack :execrows change, BuildSessionVector pure function, vector
capture in playevents.Writer, contextual_likes helpers + api handler
updates, subsonic handler updates, final verification.
2026-04-27 10:05:52 -04:00
bvandeusen 08eb1e3f8c docs(spec): add M3 session vectors + contextual likes design
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.
2026-04-27 09:56:45 -04:00
bvandeusen 08591debee feat(api): rewrite /api/radio with weighted shuffle v1
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.
2026-04-27 08:08:10 -04:00
bvandeusen 9426dc2eeb feat(config): add recommendation section (weighted shuffle weights)
BaseWeight, LikeBoost, RecencyWeight, SkipPenalty, JitterMagnitude,
RecentlyPlayedHours, RadioSize, RadioSizeMax. Defaults match spec §6.
2026-04-27 08:04:23 -04:00
bvandeusen b513c91520 feat(recommendation): add LoadCandidates DB-backed loader
Wraps the LoadRadioCandidates sqlc query, projects rows to
[]Candidate. Live-DB tests verify seed exclusion, recently-played
exclusion, stat-join correctness (likes + plays + skips +
last_played_at), and cross-user isolation.
2026-04-27 08:03:20 -04:00
bvandeusen 3ef560e414 feat(db): add LoadRadioCandidates query
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.
2026-04-27 08:01:50 -04:00
bvandeusen 8c3d06c0a1 feat(recommendation): add Shuffle orchestrator
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).
2026-04-27 07:39:16 -04:00
bvandeusen 546234187f feat(recommendation): add pure Score function with recency + skip + jitter
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.
2026-04-27 07:38:07 -04:00
bvandeusen b9937d6e3b docs(plan): add M3 weighted shuffle v1 implementation plan
7 tasks: pure score function, pure shuffle orchestrator, sqlc
LoadRadioCandidates query, DB-backed candidate loader,
RecommendationConfig + YAML, /api/radio handler rewrite, final
verification + branch finish.
2026-04-27 07:35:13 -04:00
bvandeusen f51b7c05ae docs(spec): add M3 weighted shuffle v1 design
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.
2026-04-27 01:35:51 -04:00
bvandeusen 1f08ee39bd fix(web): add likes/QueryClient mocks to route tests rendering LikeButton
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
2026-04-26 17:18:39 -04:00
bvandeusen 457b94005f feat(web): add /library/liked page with three sections
Each section is its own infinite query against /api/likes/{type}.
Empty sections show 'No liked X yet' (gated on total === 0). Reuses
ArtistRow / AlbumCard / TrackRow for rendering — same components as
the search overflow pages.
2026-04-26 17:08:06 -04:00
bvandeusen de5320a7b4 feat(web): wire LikeButton into TrackRow, AlbumCard, ArtistRow, PlayerBar
Heart appears on every entity rendering surface. ArtistRow restructured
to a <div> with the link as a positioned overlay so the heart button
can nest without invalid <button>-in-<a> markup. Shell nav grows a
'Liked' entry pointing at /library/liked.
2026-04-26 16:56:07 -04:00
bvandeusen 6ab1fef07e feat(web): add LikeButton component
Heart icon reading from createLikedIdsQuery; click toggles via
likeEntity/unlikeEntity. Click stops propagation so it can nest
inside TrackRow's role=button div without triggering row activation.
2026-04-26 16:47:51 -04:00
bvandeusen dd2b9318a0 feat(web): add likes TanStack Query helpers + mutations
createLikedIdsQuery is the single source for heart-button state across
the app (track_ids/album_ids/artist_ids Sets). likeEntity / unlikeEntity
do optimistic setQueryData updates with rollback on error. Per-entity
infinite queries back the /library/liked sections.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:46:39 -04:00
bvandeusen c7f4adbcc3 feat(subsonic): add getStarred and getStarred2 handlers
Both return user's starred artists/albums/songs sorted liked_at DESC.
Cap at 500 entries per category for v1 (Subsonic spec doesn't define
pagination on these endpoints; M3+ can revisit if needed).
2026-04-26 16:45:04 -04:00
bvandeusen 32fb3fec20 feat(subsonic): add /rest/star and /rest/unstar handlers
Validate-all-first atomicity on star: a missing entity refuses the whole
call with Subsonic error 70. Unstar is a best-effort delete (missing
entities are no-ops, matching client expectations after library moves).
2026-04-26 16:43:20 -04:00
bvandeusen 61d96bb288 feat(api): add /api/likes/{tracks,albums,artists} endpoints
Like/unlike are POST/DELETE on /api/likes/{type}/{id}; idempotent (204
on repeats). List endpoints return Page<TrackRef|AlbumRef|ArtistRef>
sorted liked_at DESC. /api/likes/ids returns flat id arrays for the
client-side heart-button cache.
2026-04-26 16:23:04 -04:00
bvandeusen 780beaa248 feat(db): add M2 likes schema (general_likes, _albums, _artists)
Three tables keyed on (user_id, entity_id) with liked_at. Per-table
indexes on (user_id, liked_at DESC) for the recently-liked feed.
sqlc queries cover like/unlike/list-rows/count/list-ids per entity.
2026-04-26 16:20:08 -04:00
bvandeusen a3c05aeb34 docs(plan): add M2 likes implementation plan
9 tasks: schema + sqlc, native API, Subsonic star/unstar,
Subsonic getStarred/getStarred2, web likes.ts factory + mutations,
LikeButton component, wire LikeButton into existing components,
/library/liked page, final verification.
2026-04-26 15:52:42 -04:00
bvandeusen f91257a79e docs(spec): add M2 likes sub-plan design
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.
2026-04-26 15:44:45 -04:00
bvandeusen 6ff00ea24d 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.
2026-04-26 09:44:16 -04:00
bvandeusen b6f8a0c390 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-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.
2026-04-26 09:42:11 -04:00
bvandeusen 599a19f780 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.

api.Mount now takes the shared *playevents.Writer instead of cfg so
the same writer instance feeds both surfaces.
2026-04-26 00:23:20 -04:00
bvandeusen f4e73b81b4 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. Mount signature now takes EventsConfig and constructs
the playevents.Writer; server.New propagates it through.
2026-04-26 00:12:06 -04:00
bvandeusen 0c7e1b0408 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.
2026-04-26 00:10:02 -04:00
bvandeusen 73d3962fff 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.
2026-04-25 22:30:12 -04:00
bvandeusen 722a6784a7 feat(config): add events section (session timeout + skip rule thresholds) 2026-04-25 20:46:23 -04:00
bvandeusen 4fcf2c9616 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.
2026-04-25 20:45:44 -04:00
bvandeusen 7df48ee9bc docs(plan): add M2 events sub-plan implementation plan
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.
2026-04-25 19:56:58 -04:00
bvandeusen f3b28db9d1 docs(spec): add M2 events sub-plan design
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.
2026-04-25 19:39:16 -04:00
bvandeusen 7fff293054 feat(web): add three search overflow pages
/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).
2026-04-25 16:19:09 -04:00
bvandeusen e55520dab6 feat(web): /search page with three sections + see-all overflow links
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.
2026-04-25 16:16:55 -04:00
bvandeusen ad9c984b62 feat(web): mount SearchInput in Shell header
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).
2026-04-25 16:12:19 -04:00
bvandeusen 3be0dc241c feat(web): add SearchSkeleton placeholder for the search page 2026-04-25 16:12:19 -04:00
bvandeusen 251d165e0b feat(web): add SearchInput header component (debounced URL sync)
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.
2026-04-25 16:01:25 -04:00
bvandeusen a4a5a20308 feat(web): AlbumCard + queue overlay button
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.
2026-04-25 15:54:18 -04:00