40 Commits

Author SHA1 Message Date
kami 57120b872d fix(sync): let a phone pause the desktop, and let a Vibe follow the audio
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Six bugs in the single transport and in how a browser registers as a device,
then the feature the fourth one was hiding.

A watching device keeps its own audio paused, so every control that read
`isPlaying` from the store drew a Play button while the desktop played — and
sent `play` when it was pressed. The transport now carries one `playing` value:
the remote state while presses are being forwarded, the local one otherwise.

Two tabs of one browser shared a stored device id, which made them one device
that ran every command twice and played two copies of the audio. A device id is
now held by whichever stream has it open: registration refuses to hand back a
busy id, and each tab keeps its own in `sessionStorage`.

A device that was only showing what another one plays still pointed an audio
element at the stream, downloading tracks it would never play. It now loads
nothing while the audio is elsewhere, and reloads the moment it comes back.

Commands were accepted for an owner with no stream to receive them on, so a
killed tab answered a press with a success it never got. Ownership outlives a
closed stream deliberately; delivery does not.

The event stream never called `reply.hijack()`, leaving Fastify waiting on a
handler that resolves with nothing.

And the Vibe: `setQueue` is an ownership handoff, so a snapshot from another
device dropped the advance handler that asks the server for the next track. A
session moved to a phone became a fixed list of the hundred tracks that
happened to be synced. Vibe control now follows the audio — the snapshot
carries the session id, the device losing the audio stops driving, and the one
gaining it resumes the durable session and takes over replanning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:04:03 +04:00
kami a75d36b821 fix(db): repair the two things the scanner quietly undid
Both migrations exist because of scanner behaviour fixed in this branch,
and both are idempotent.

31 tracks the listener had disliked were sitting back in LIBRARY,
restored by successive library scans, and were being served again. They
return to HIDDEN unless the dislike was deliberately reverted.

740 tracks had artist tags but no edge in the claim graph, which put
them out of reach of every Vibe generator but the fallback. Their
credits are written from track_artists, the same shape the scanner now
produces at scan time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 14:00:32 +04:00
kami 5a019bd35c perf(vibe): stop building a plasma and reading the library on a phone
Two things made the Vibe page crawl on mobile.

The ambient aura pushed three counter-rotating layers through an SVG
turbulence and displacement pass whose noise field was animated in SMIL.
SVG filters rasterise on the CPU, so that regenerated the whole field
every frame, under a blur, across a 420x380 element. Below the
breakpoint the filter is no longer mounted and the layers churn behind a
plain blur instead. A phone gets the motion, not the noise pass.

The start screen read all 5,000 tracks to show fifty of them. The sample
is now drawn in the database, which keeps Surprise me uniform over the
whole library while sending only what is on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 14:00:19 +04:00
kami 0a01085ed0 fix(discovery): give every track a place in the graph, not just the ones MusicBrainz knows
track_artists_v2 is a view over claim_fusion, and only the MusicBrainz
spine wrote credited_main_on claims. The scanner filled track_artists
and left the spine alone, so the tag-derived backfill migration from
July was the only thing that ever put local credits in the graph.

Every track scanned since was therefore invisible to all but one Vibe
generator — 740 of them, including 20 of the 26 acquired
recommendations, all of which had perfectly good artist tags sitting
unused. Obscure music is exactly what MusicBrainz does not know and
exactly what a self-hosted library is full of.

The scanner now writes those credits itself. Source is `tag`, the
lowest trust in the spine, so a later MusicBrainz claim about the same
edge still outranks it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 14:00:04 +04:00
kami d28a92803b feat(vibe): offer the four fifths of the library it never reached
Of 5,365 tracks, 692 had ever been played and 1,027 had ever entered a
plan. Four things kept the Vibe circling the same music.

Comfort took each top artist's two highest-scoring tracks in a fixed
order, so it proposed the same eighteen candidates in every session for
as long as the listener's top artists held still. It now samples two
from each artist's best ten.

A track the listener has never played now scores as if it carried real
relevance. Without that, library-fallback — the one generator that
samples the whole library evenly — entered at 0.05 and lost every slot.

A quarter of every plan is now reserved for unheard tracks outright,
taken from the tail so the track about to play keeps its shaping.

Hard exclusion reached back forty completed plays, so a track heard last
night was a candidate again tonight. It now covers everything heard in
the last fourteen days, counted from a durable serve or a play and never
from a plan item, since most planned tracks are replaced unheard. A cap
keeps the window from ever holding back more than 40% of the library.

Library-fallback also offers probation recommendations now. It is the
only generator that does not walk the graph, so it is how an acquired
recommendation gets heard before anything has enriched it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 13:59:44 +04:00
kami 60085c1d72 fix(playback): stop a moment without signal from pausing the phone
A phone changing cell or locking its screen drops the push stream for a
few seconds while its audio keeps playing. The server released session
ownership the instant that stream closed, and the phone read its own
reconnect snapshot as another device taking over: it paused, and rewound
to whatever position it had last reported. Ownership now survives a
closed stream. A device that is really gone still loses the session, via
pagehide and via the sweep that frees an owner whose heartbeat stopped.

The client no longer treats an unowned session as an instruction to
stop. With audio loaded it claims the session back instead.

Two ways a phone could go quiet until the page was reloaded are also
gone: registration is retried rather than attempted once, and the push
stream reopens after an error status, which EventSource treats as final.
It also checks itself when the network or the tab comes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 13:59:29 +04:00
kami 5a73a6a6f3 fix(vibe): let a dislike outlive the session that heard it
Disliking a track in a Vibe wrote one row to the session ledger and
nothing else. The ledger only excludes a track from the session it was
recorded in, so the same track came back the next evening, and the one
after that. A dislike in a Vibe is the same verdict as a dislike
anywhere else, so it now takes the same path.

Two more things undid a dislike that did land. The library scan rewrote
every track's state from the file on disk, which restored every HIDDEN
track to LIBRARY on every scan; finding a file again says nothing about
whether the listener wants to hear it. And hiding only matched tracks in
LIBRARY, so a disliked probation recommendation stayed eligible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 13:59:16 +04:00
kami 0749f6ad10 feat(playback): buffer the next track early, look further ahead less
The served Vibe preview was eight items deep, and every one of them cost
a track fetch on each advance while buying nothing but a longer Up next
list. Three is enough to show where the stream is going.

The audio prefetch was the opposite problem: it only began twenty
seconds before the end, so a phone that lost signal in that window
arrived at the handover with nothing buffered. It now starts fifteen
seconds into the current track, which gives the rest of the song to pull
the next one down. Buffering that early means a replan can change the
answer, so the idle element is re-pointed when it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:38:58 +04:00
kami 78f5feea11 fix(vibe): let a session survive a phone losing its connection
A locked screen drops the radio, changes network or dozes, and one
request fails. Both halves of an advance treated that as the session's
fault. The outbox kept the failed event at its head with the comment
that a later retry would pick it up, but nothing ever triggered one, so
it sat there while the caller was rejected. advanceVibe then cleared the
prefetched future and paused, destroying a plan that was still valid.

Transient failures now hold the outbox entry unsettled and resend the
same event id on a backoff, so no duplicate feedback reaches the
director. The retry around the serve sits on the serve alone: serving a
version is idempotent, while replaying the whole advance would report a
second outcome for a track heard once. Each wait ends early when the
browser says the network is back, which is the moment that matters when
a screen unlocks. A drop that outlives every retry leaves the future
intact to carry on from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:28:50 +04:00
kami 93c737ee49 fix(playback): stop the device holding the audio rewinding itself
The owner reports its position every ten seconds, and the server
publishes a snapshot to every device on anything that touches the
session. The owner was writing those snapshots back into its own store,
so each one dragged playback back to its last report. Reloading another
tab did it too, since registering a device publishes a snapshot: the
phone jumped back to whatever position was on record, which right after
a track change is zero, and the song started over.

A device that already owns the session now ignores incoming snapshots
and stays the authority on its own position. Snapshots still apply when
ownership moves to or away from this device, which is what that branch
was written for. Moving audio between devices also flushes the real
position first, rather than handing over a ten-second-old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:28:33 +04:00
kami dea08f9c47 feat(mobile): install Muzick to the home screen, and get there quickly
Adds the manifest, icons and service worker that make the app
installable, and offers it as a toast once Chrome says it qualifies.
Declining snoozes the offer for a month; installing ends it.

A waiting service worker never activates on its own. Reloading the page
under a listener to swap in a new build would cut the song they are in
the middle of, so updates land on the next cold start instead. Audio is
kept out of the cache entirely: range requests and multi-megabyte bodies
do not belong in a shell cache. Artwork is cached, and the SPA
navigation fallback denies /api so it cannot swallow the event stream.

Installed on Android the app paints edge to edge, so the transport pads
itself past the gesture bar. MediaSession gains setPositionState, which
is what gives the notification shade a seek bar that moves.

Three things kept the bundle from ever being compressed, each hiding the
next: the nginx image ships with gzip off, gzip_proxied defaults to off
and skips anything carrying a Via header, and gzip_http_version defaults
to 1.1 while the host proxy speaks 1.0. With those fixed and the pages
split per route, the first load goes from 555KB to 60KB of app code plus
a vendor chunk that survives redeploys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:28:19 +04:00
kami a88ae62db1 docs: name the compose command that actually works here
The v1 `docker-compose` binary on the deploy host fails with
KeyError: 'ContainerConfig' when it recreates a container, so every
instruction pointed at a command that cannot finish a deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:27:59 +04:00
kami 85ca9cf543 fix(ui): one transport for every control, and a queue panel that fits
The queue panel drove the playback store directly, so its buttons played
locally while another device held the audio. Both control sets now go
through one transport that forwards a press when the audio is elsewhere.

The panel's artwork is capped against viewport height too: at full width
on a phone the square alone pushed Up Next off the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:56:30 +04:00
kami 4ead344aec feat(playback): move a session between devices
One device holds the audio; the rest watch the same session over an
event stream and act as remotes. Picking a device hands the audio over
at the position the previous one reported, and that device stops.

Also centre the command palette with margins instead of a translate:
animate-rise sets its own transform and dropped the offset on mobile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 19:12:26 +04:00
kami bfe22745bc feat(discovery): acquire recommendations that keep their names
Acquisition ran yt-dlp without --embed-metadata, so every download
arrived untagged. The scanner then stored the video id as the title and
"Unknown Artist" as the artist, the vetted-candidate tag check rejected
the mismatch, and all 18 acquired tracks were hidden and retired.

- Pass --embed-metadata so downloads carry real tags.
- Let a scan take fallback title/artist from the candidate, for sources
  that still ship untagged files.
- Install Deno alongside yt-dlp: YouTube guards some formats with a JS
  challenge yt-dlp must execute, and no other runtime is enabled.
- Dedupe candidates by artist and title. The (source, external_id) key
  misses the same song reaching us under two Deezer release ids.

Also carries the in-flight discovery work this builds on: the
Recommendations page replacing Discover, the discovery source service,
and the acquisition spec tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 18:43:58 +04:00
kami d371bd97f3 fix(images): allow the CDNs artwork actually lives on, add Deezer artist photos
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
The image proxy allowlist covered none of the hosts enrichment writes, so every
album cover answered 403: 305 covers sit on coverartarchive.org (which 302s to
archive.org and on to an ia*.us.archive.org node), 126 on mzstatic, 115 on
dzcdn, 26 on discogs.

Artist images were empty for a different reason — no working source. Fanart
needs a key the worker does not have, TheAudioDB and Discogs 404 on most names,
Wikidata needs an MBID that 638 of 734 artists lack, and Last.fm stopped
serving photos. Deezer needs no auth and its host is already allowlisted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:23:03 +04:00
kami 93619824d8 feat(ui): rework every page for comfort, and make a seeded Vibe play its seed
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
A pass over the whole app against the Ethos laws, then a focused pass on
Vibe with the operator reviewing each change.

Across the app:
- The player bar restores the last track it played, paused at zero, so a
  fresh tab opens on where the listener was instead of "nothing playing".
- Track titles link to their album, matching the artist links beside them.
  Playback stays on the artwork tile; a title that played was the surprise.
- The search field is bg-bg2. Tailwind cannot alpha-modify these var()
  colors, so bg-surface0/70 emitted no rule at all and the input fell back
  to the UA's white.
- Row hover is light falling off to the right, not a flat slab.
- The artwork placeholder can drop its note glyph, so TrackRow no longer
  layers a play icon on top of one.

Vibe:
- A seeded Vibe plays its seed first. The seed sits in front of the durable
  plan without being part of it, so the first advance consumes it locally
  and reports no plan feedback.
- The queue drops a second recording of a song it already holds — same
  title, different track id, which id-based dedup let through.
- Up next is read from the queue rather than the plan preview, since the
  seed is not a plan item.
- The header carries the live profile (energy, discovery, goal) and both
  verbs. Keep is gone: letting a track finish already reports `completed`,
  which the director weighs the same.
- The aura is one warm diffuse blob in the page background, warm-hued only
  and quieter on mobile.
- Compact artwork is 32px. It was h-8 w-8, which this remapped spacing
  scale renders as 64px inside a 44px row, and that overflow was the
  "stacked" look.

Verified by render at 1440x900 and 390x844, no horizontal overflow at
either. 26 frontend and 122 backend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:53:46 +04:00
kami a7d126787f feat(vibe): weigh local calendar context in session planning
The director had no idea what hour or season a session started in, so a
22:00 weeknight and a Sunday morning drew from the same pool. The client
now sends localHour, weekday, month and an optional timeZone; the route
validates and bounds all four, the coordinator threads them through, and
the generators use them as scoring signals.

Also exposes GET /library/stats, which the Home page counts read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:53:22 +04:00
kami 8f33744f8c fix(playback): remove the stutter and gap between tracks
The isPlaying subscriber is unselected. Every store write during Vibe's
feedback/replan handshake called play() on the element that had just ended.
That replayed its final buffered milliseconds until the next source loaded.
Gate that subscriber and the seek subscriber on an actual value change, and
never resume a finished element.

Then close the gap the handshake leaves behind. The engine now drives two
<audio> elements. The next track buffers into the idle one 20s early. The
handover starts before `ended`, so the round-trip happens under the outgoing
tail. With a crossfade, that tail fades out under the new track. With crossfade
off, the new track waits in silence and starts the moment the tail ends.

Both are configurable under Settings -> Transitions and persist to
localStorage. Preload is on and crossfade is 400ms by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:18:28 +04:00
kami ce16bb94f8 fix(vibe): let ambient profile aura cross track rows
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:38:27 +04:00
kami c0a3eeee4b style(vibe): enlarge interactive profile aura
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:33:48 +04:00
kami 68bf299296 style(vibe): center the ambient profile aura
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:30:53 +04:00
kami 03746a3899 refactor(vibe): make profile aura ambient
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:27:28 +04:00
kami 092a43b981 feat(vibe): visualize the listening profile
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:22:58 +04:00
kami ec141b32f4 feat(vibe): explain recommendation signals
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:16:26 +04:00
kami 1f44af5893 fix(vibe): keep playback advancing with full plans
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:12:22 +04:00
kami 6b40cf7a9c fix(vibe): bind next plan item query correctly
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-03 14:06:45 +04:00
kami dfb8ed6f28 Merge pull request 'Feat/vibe v2 session director' (#4) from feat/vibe-v2-session-director into master
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Reviewed-on: #4
2026-08-03 11:23:09 +02:00
kami 9eba247a58 refactor(vibe): simplify durable session flow
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Typecheck / typecheck (backend) (pull_request) Has been cancelled
Typecheck / typecheck (workers) (pull_request) Has been cancelled
2026-08-03 12:44:08 +04:00
kami 61a1373ca9 feat(vibe): adapt sessions to context and exploration
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Typecheck / typecheck (backend) (pull_request) Has been cancelled
Typecheck / typecheck (workers) (pull_request) Has been cancelled
2026-08-02 02:08:48 +04:00
kami fe13798c99 feat(vibe): plan musical arcs and callbacks 2026-08-02 00:49:27 +04:00
kami 89a23e3703 feat(vibe): enforce session diversity constraints 2026-08-02 00:14:39 +04:00
kami 57df1cfe9f feat(vibe): reconcile mutable session previews in playback 2026-08-01 23:47:11 +04:00
kami 51ef7c84db feat(vibe): add durable versioned session API 2026-08-01 23:09:09 +04:00
kami 515cab2f89 feat(vibe): persist durable session plans and events 2026-08-01 22:32:42 +04:00
kami 3641ec9e8e docs: add Vibe v2 session director specification 2026-08-01 22:23:12 +04:00
kami 5378af6af1 fix: rely on vibe fatigue instead of artist counters
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Typecheck / typecheck (backend) (pull_request) Has been cancelled
Typecheck / typecheck (workers) (pull_request) Has been cancelled
2026-08-01 21:42:27 +04:00
kami e62b7e8d10 fix: soften vibe artist exclusions
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-01 16:27:55 +04:00
kami 4c48d11e9d feat: enhance discovery, vibe sessions, and library enrichment
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
2026-08-01 14:40:48 +04:00
kami a0c9f42a89 Merge pull request 'Repair the 2026-07-30 review findings' (#1) from repair/review-2026-07-30 into master
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Reviewed-on: #1
2026-07-30 22:48:14 +02:00
118 changed files with 20616 additions and 1538 deletions
+3 -1
View File
@@ -28,7 +28,9 @@ npm run build # vite build
npm run typecheck
# whole stack
docker-compose up -d --build
# Use `docker compose` (v2). The old `docker-compose` v1 binary on this machine
# crashes with KeyError: 'ContainerConfig' when recreating a container.
docker compose up -d --build
```
There is no lint step. `typecheck` is the gate; the `prebuild` hook fails the build on type errors.
+5
View File
@@ -39,6 +39,11 @@ A high-performance, distributed music orchestration and recommendation platform.
- Docker & Docker Compose
### Running Locally
Vibe uses the same per-user identity convention as the rest of the API:
`x-user-id` when supplied, otherwise the local default user. Each user's Vibe
session and listening history are isolated from other users.
```bash
docker-compose up -d
```
+56 -3
View File
@@ -14,9 +14,17 @@ import quarantineRoutes from './routes/quarantine.routes.js';
import settingsRoutes from './routes/settings.routes.js';
import graphRoutes from './routes/graph.routes.js';
import { SessionDirector } from './services/session-director.service.js';
import v2Routes from './routes/v2.routes.js';
import vibeSessionsRoutes from './routes/vibe-sessions.routes.js';
import discoveryRoutes from './routes/discovery.routes.js';
import imagesRoutes from './routes/images.routes.js';
import { VibeSessionCoordinator } from './services/vibe-session-coordinator.service.js';
import { DiscoveryService } from './services/discovery.service.js';
import { PlaybackSyncService } from './services/playback-sync.service.js';
import playbackRoutes from './routes/playback.routes.js';
// Single-listener deployment: the same placeholder the vibe-session routes use
// when no x-user-id header is supplied.
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
export interface AppConfig {
port: number;
@@ -90,6 +98,44 @@ export async function buildApp(config: AppConfig) {
dbService.decayBeliefs().catch(() => {});
dbService.deriveForgottenProfile().catch(() => {});
// Close the discovery loop without an operator in it. The worker's cron jobs
// generate candidates; this timer is what decides which of them earn a
// probation slot and enqueues the download. Evaluation is cheap SQL against a
// capped batch, and every acquisition gate (relevance, novelty tolerance,
// backlog, per-artist diversity) lives inside evalCandidates.
const discoveryService = new DiscoveryService(dbService);
const DISCOVERY_EVAL_INTERVAL_MS = 30 * 60 * 1000;
const runDiscoveryEval = async () => {
const results = await discoveryService.evalCandidates(DEFAULT_USER_ID);
let enqueued = 0;
for (const result of results) {
if (!result.shouldAcquire) continue;
try {
await jobService.enqueueDiscoveryAcquisition(result.candidateId);
enqueued++;
} catch (err) {
const reason = err instanceof Error ? err.message : 'failed to enqueue acquisition';
await discoveryService.markEnqueueFailed(result.candidateId, reason);
}
}
if (results.length > 0) {
console.log(`[Discovery] Evaluated ${results.length} candidates, enqueued ${enqueued}`);
}
};
const discoveryEvalTimer = setInterval(() => {
runDiscoveryEval().catch((e) => console.error('[Discovery] eval failed:', e));
}, DISCOVERY_EVAL_INTERVAL_MS);
// Cross-device playback. A device that dies without closing its stream — a
// phone going to sleep, a laptop lid — leaves the session owned by something
// that will never play again, so a sweep frees ownership once its heartbeat
// has been silent past the staleness window.
const playbackSync = new PlaybackSyncService(pgPool);
const DEVICE_REAP_INTERVAL_MS = 60 * 1000;
const deviceReapTimer = setInterval(() => {
playbackSync.reapStaleDevices().catch((e) => console.error('[Playback] device reap failed:', e));
}, DEVICE_REAP_INTERVAL_MS);
// Ensure the Typesense 'tracks' collection schema exists on boot so that
// the first search request doesn't hit a 404.
await searchService.ensureCollection();
@@ -158,9 +204,14 @@ export async function buildApp(config: AppConfig) {
fastify.register(graphRoutes, { prefix: '/api', dbService });
const sessionDirector = new SessionDirector(dbService);
const vibeSessionCoordinator = new VibeSessionCoordinator(dbService, sessionDirector);
fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector });
fastify.register(discoveryRoutes, { prefix: '/api', dbService });
fastify.register(vibeSessionsRoutes, {
prefix: '/api',
coordinator: vibeSessionCoordinator,
});
fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService });
fastify.register(playbackRoutes, { prefix: '/api', playbackSync });
// ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed —
// nothing in the deployed app or its tests called it, and deployment never
// sets NODE_ENV so an env gate would've stayed live in prod anyway.
@@ -171,6 +222,8 @@ export async function buildApp(config: AppConfig) {
clearInterval(fusionTimer);
clearInterval(decayTimer);
clearInterval(forgottenTimer);
clearInterval(discoveryEvalTimer);
clearInterval(deviceReapTimer);
} catch (err) {
fastify.log.error(err);
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { MIGRATIONS } from './migrations.js';
describe('track release-date migration', () => {
const migration = MIGRATIONS.find(
({ id }) => id === '20260801_track_release_dates_from_albums',
);
it('backfills from albums with a null-safe, deterministic conflict rule', () => {
expect(migration).toBeDefined();
expect(migration!.sql).toContain('ALTER TABLE albums ADD COLUMN IF NOT EXISTS release_date DATE');
expect(migration!.sql).toContain('UPDATE tracks AS t');
expect(migration!.sql).toContain('SET release_date = al.release_date');
expect(migration!.sql).toContain('t.release_date IS DISTINCT FROM al.release_date');
});
it('keeps new tracks and album metadata updates synchronized', () => {
expect(migration!.sql).toContain('BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks');
expect(migration!.sql).toContain('AFTER UPDATE OF release_date ON albums');
expect(migration!.sql).toContain('WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)');
});
});
describe('Vibe durable session migration', () => {
const migration = MIGRATIONS.find(
({ id }) => id === '20260801_vibe_session_persistence',
);
it('creates the event ledger, retry key, and revisioned plan tables', () => {
expect(migration).toBeDefined();
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_sessions');
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_events');
expect(migration!.sql).toContain('UNIQUE (session_id, client_event_id)');
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_plan_versions');
expect(migration!.sql).toContain('UNIQUE (session_id, version)');
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_plan_items');
expect(migration!.sql).toContain('UNIQUE (plan_version_id, track_id)');
});
it('indexes session and event reads along their required time axes', () => {
expect(migration!.sql).toContain('idx_vibe_sessions_user_last_event');
expect(migration!.sql).toContain('idx_vibe_events_session_occurred');
expect(migration!.sql).toContain('idx_vibe_events_user_occurred');
});
});
describe('Vibe context memory migration', () => {
it('adds bounded session exploration state and exactly-once projection storage', () => {
const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_context_memory_exploration');
expect(migration?.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_session_profiles');
expect(migration?.sql).toContain('exploration_coefficient');
expect(migration?.sql).toContain('vibe_session_feedback_projections');
});
it('backfills profiles for durable sessions created before context memory', () => {
const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_session_profile_backfill');
expect(migration?.sql).toContain('INSERT INTO vibe_session_profiles');
expect(migration?.sql).toContain('SELECT id, user_id FROM vibe_sessions');
expect(migration?.sql).toContain('ON CONFLICT (session_id) DO NOTHING');
});
});
+393
View File
@@ -510,4 +510,397 @@ export const MIGRATIONS: Migration[] = [
ON pending_file_deletions(requested_at);
`,
},
{
// System E originally wrote graph_exploration claims without registering it
// in source_trust, so the FK rejected every graph walk. It also lacked a
// durable link from an acquired candidate to the track the scanner created.
id: '20260801_acquisition_pipeline',
sql: `
INSERT INTO source_trust (key, trust, description)
VALUES ('graph_exploration', 0.40,
'Muzick graph traversal provenance for discovery candidates.')
ON CONFLICT (key) DO NOTHING;
ALTER TABLE discovery_candidates
ADD COLUMN IF NOT EXISTS acquired_track_id UUID
REFERENCES tracks(id) ON DELETE SET NULL;
ALTER TABLE discovery_candidates
ADD COLUMN IF NOT EXISTS acquired_at TIMESTAMPTZ;
ALTER TABLE discovery_candidates
ADD COLUMN IF NOT EXISTS acquisition_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE discovery_candidates
ADD COLUMN IF NOT EXISTS last_error TEXT;
CREATE INDEX IF NOT EXISTS idx_discovery_candidates_status
ON discovery_candidates (status, first_seen_at);
`,
},
{
// Separate portrait fetching from structural metadata so an operator can
// re-enrich missing artist images without implicitly enabling MB writes.
id: '20260801_artist_image_enrichment_toggle',
sql: `
INSERT INTO settings (key, value)
VALUES ('enrich_artist_images', 'false')
ON CONFLICT (key) DO NOTHING;
`,
},
{
// Audio analysis v1 stored an arbitrary linear energy scale, which made
// nearly every mastered track look maximally energetic. Track the analysis
// contract and source hash so bounded background jobs can safely refresh
// stale results without repeatedly decoding unchanged files.
id: '20260801_audio_analysis_v2',
sql: `
ALTER TABLE track_audio_features
ADD COLUMN IF NOT EXISTS analysis_version SMALLINT NOT NULL DEFAULT 0;
ALTER TABLE track_audio_features
ADD COLUMN IF NOT EXISTS source_hash TEXT;
ALTER TABLE track_audio_features
ADD COLUMN IF NOT EXISTS analyzed_at TIMESTAMPTZ;
DO $mig$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'track_audio_features'::regclass
AND conname = 'track_audio_features_bpm_range'
) THEN
ALTER TABLE track_audio_features
ADD CONSTRAINT track_audio_features_bpm_range
CHECK (bpm IS NULL OR (bpm >= 30 AND bpm <= 300)) NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'track_audio_features'::regclass
AND conname = 'track_audio_features_energy_range'
) THEN
ALTER TABLE track_audio_features
ADD CONSTRAINT track_audio_features_energy_range
CHECK (energy IS NULL OR (energy >= 0 AND energy <= 1)) NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'track_audio_features'::regclass
AND conname = 'track_audio_features_danceability_range'
) THEN
ALTER TABLE track_audio_features
ADD CONSTRAINT track_audio_features_danceability_range
CHECK (danceability IS NULL OR (danceability >= 0 AND danceability <= 1)) NOT VALID;
END IF;
END $mig$;
`,
},
{
// tracks.release_date is a denormalised copy of the canonical
// MusicBrainz release-group date on albums. Before this migration the
// worker populated albums only, leaving every historical track invisible
// to the novelty generator. The backfill and triggers make the canonical
// album value win deterministically for both old and future rows.
id: '20260801_track_release_dates_from_albums',
sql: `
-- The worker used to create this column lazily, but migration execution
-- must not rely on worker startup order.
ALTER TABLE albums ADD COLUMN IF NOT EXISTS release_date DATE;
-- Backfill only mismatches. IS DISTINCT FROM is null-safe and makes the
-- statement safe to rerun manually for diagnostics/recovery.
UPDATE tracks AS t
SET release_date = al.release_date
FROM albums AS al
WHERE t.album_id = al.id
AND t.release_date IS DISTINCT FROM al.release_date;
CREATE OR REPLACE FUNCTION sync_track_release_date_from_album()
RETURNS TRIGGER AS $mig$
BEGIN
SELECT release_date INTO NEW.release_date
FROM albums
WHERE id = NEW.album_id;
RETURN NEW;
END;
$mig$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_tracks_sync_release_date ON tracks;
CREATE TRIGGER trg_tracks_sync_release_date
BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks
FOR EACH ROW EXECUTE FUNCTION sync_track_release_date_from_album();
CREATE OR REPLACE FUNCTION propagate_album_release_date_to_tracks()
RETURNS TRIGGER AS $mig$
BEGIN
UPDATE tracks
SET release_date = NEW.release_date
WHERE album_id = NEW.id
AND release_date IS DISTINCT FROM NEW.release_date;
RETURN NEW;
END;
$mig$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_albums_propagate_release_date ON albums;
CREATE TRIGGER trg_albums_propagate_release_date
AFTER UPDATE OF release_date ON albums
FOR EACH ROW
WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)
EXECUTE FUNCTION propagate_album_release_date_to_tracks();
`,
},
{
// Vibe v2 needs an immutable event ledger and revisioned plans. The
// legacy session_state table remains in place as a derived-state cache so
// existing v2 endpoints can migrate independently.
id: '20260801_vibe_session_persistence',
sql: `
CREATE TABLE IF NOT EXISTS vibe_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')),
seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
context JSONB NOT NULL DEFAULT '{}'::jsonb,
policy_version TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
ON vibe_sessions (user_id, last_event_at DESC);
CREATE TABLE IF NOT EXISTS vibe_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_event_id UUID,
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
position_ms INTEGER,
duration_ms INTEGER,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (session_id, client_event_id)
);
CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred
ON vibe_events (session_id, occurred_at);
CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred
ON vibe_events (user_id, occurred_at DESC);
CREATE TABLE IF NOT EXISTS vibe_plan_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
version INTEGER NOT NULL CHECK (version > 0),
reason TEXT NOT NULL,
state_snapshot JSONB NOT NULL,
objective_snapshot JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (session_id, version)
);
CREATE TABLE IF NOT EXISTS vibe_plan_items (
plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
slot_role TEXT,
candidate_source TEXT NOT NULL,
score REAL NOT NULL,
score_breakdown JSONB NOT NULL,
explanation JSONB NOT NULL,
committed BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (plan_version_id, ordinal),
UNIQUE (plan_version_id, track_id)
);
`,
},
{
// A material Vibe event is projected into the legacy listener inputs in
// the same transaction as its ledger write. This marker makes that bridge
// auditable and exactly-once even when a client retries an event id.
id: '20260801_vibe_event_projections',
sql: `
CREATE TABLE IF NOT EXISTS vibe_event_projections (
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`,
},
{
// Session-specific exploration, goals, and deliberately lossy session
// fingerprints are derived from the immutable Vibe ledger. Keeping them
// separate from listener_beliefs prevents a transient session from
// rewriting permanent taste.
id: '20260802_vibe_context_memory_exploration',
sql: `
CREATE TABLE IF NOT EXISTS vibe_session_profiles (
session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb,
goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb,
exploration_coefficient REAL NOT NULL DEFAULT 0.30
CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1),
discovery_radius REAL NOT NULL DEFAULT 0.38
CHECK (discovery_radius >= 0 AND discovery_radius <= 1),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated
ON vibe_session_profiles (user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections (
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`,
},
{
// The profile table was introduced after durable sessions. Backfill every
// pre-existing session before feedback can claim its exactly-once marker;
// newly created sessions receive their context-derived initial goals in
// createVibeSession's transaction.
id: '20260802_vibe_session_profile_backfill',
sql: `
INSERT INTO vibe_session_profiles (session_id, user_id)
SELECT id, user_id FROM vibe_sessions
ON CONFLICT (session_id) DO NOTHING;
`,
},
{
// Two external candidate strategies, kept as separate source_trust keys so
// meta-learning can compare their retention independently: "artists you
// already play just released something" is a much stronger prior than
// "Last.fm thinks this sounds similar", and the trust values say so.
id: '20260806_external_discovery_sources',
sql: `
INSERT INTO source_trust (key, trust, description) VALUES
('new_release', 0.60,
'New release by an artist already played from the local library.'),
('similar_recommendation', 0.45,
'External similarity (Last.fm) seeded from local play history.')
ON CONFLICT (key) DO NOTHING;
`,
},
{
// play_history is the only durable record of what was listened to and when,
// and it had two holes that only show up when you try to read a year back:
//
// 1. ON DELETE CASCADE meant the gated cleanup sweep silently erased the
// plays of every file it removed. A play happened; deleting the file
// later does not un-happen it. The FK becomes SET NULL and the track's
// identity is denormalised onto the row so it stays readable.
// 2. No duration, so listening time was only ever inferable from the
// track's current duration — itself gone once the file is.
id: '20260806_play_history_durable_facts',
sql: `
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS listened_ms INTEGER;
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS track_title TEXT;
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS track_artist TEXT;
DO $$
DECLARE fk_name TEXT;
BEGIN
SELECT con.conname INTO fk_name
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_attribute att ON att.attrelid = rel.oid AND att.attnum = con.conkey[1]
WHERE rel.relname = 'play_history'
AND con.contype = 'f'
AND att.attname = 'track_id'
AND con.confdeltype = 'c'
LIMIT 1;
IF fk_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE play_history DROP CONSTRAINT %I', fk_name);
ALTER TABLE play_history
ADD CONSTRAINT play_history_track_id_fkey
FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL;
END IF;
END $$;
-- Backfill identity for rows written before the columns existed. Rows whose
-- track was already cascade-deleted are unrecoverable; this at least stops
-- the bleeding from here on.
UPDATE play_history ph
SET track_title = t.title, track_artist = t.artist
FROM tracks t
WHERE t.id = ph.track_id AND ph.track_title IS NULL;
`,
},
{
// One listener, many browsers. `playback_state` is the single authority for
// what is playing and which device owns the audio, so a phone can take over
// from a desktop mid-track. The queue is stored as whole track objects, not
// ids: the device taking over needs to render the queue immediately, and a
// snapshot of what was queued at handoff time is the honest thing to move.
id: '20260808_playback_devices',
sql: `
CREATE TABLE IF NOT EXISTS playback_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
name TEXT NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_playback_devices_user
ON playback_devices (user_id, last_seen_at DESC);
CREATE TABLE IF NOT EXISTS playback_state (
user_id UUID PRIMARY KEY,
device_id UUID REFERENCES playback_devices(id) ON DELETE SET NULL,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
queue JSONB NOT NULL DEFAULT '[]'::jsonb,
queue_index INTEGER NOT NULL DEFAULT -1,
position_ms INTEGER NOT NULL DEFAULT 0,
is_playing BOOLEAN NOT NULL DEFAULT FALSE,
-- Monotonic per user. A device applies a snapshot only when it is newer
-- than the last one it saw, so a delayed delivery cannot rewind anyone.
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`,
},
{
// Every library scan used to reset a disliked track from HIDDEN back to
// LIBRARY, so tracks the listener had rejected kept returning in the Vibe.
// The scanner no longer does that; this puts the tracks it already undid
// back where the dislike left them. A track whose dislike was deliberately
// restored has no `dislikes` row and is untouched here.
// `20260707_backfill_claims` wrote tag-derived credits once, and nothing
// wrote them afterwards: the scanner filled `track_artists` but left the
// claim spine to MusicBrainz. Every track added since, and every track
// MusicBrainz does not know, therefore had no edge in `claim_fusion` and
// was unreachable by all but one Vibe generator. The scanner now writes
// these itself; this catches up the tracks it already missed.
id: '20260810_backfill_tag_credits_since_first_scan',
sql: `
INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence)
SELECT 'track', ta.track_id,
CASE WHEN ta.role = 'main' THEN 'credited_main_on' ELSE 'featured_on' END,
'artist', ta.artist_id, 'tag', 1.0
FROM track_artists ta
JOIN tracks t ON t.id = ta.track_id
WHERE t.state IN ('LIBRARY', 'RECOMMENDED')
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
DO NOTHING;
`,
},
{
// Vibe control has to follow the audio. The running session's id rides with
// the playback snapshot, so the device taking the audio can adopt the
// session instead of walking whatever queue it happened to receive — which
// is what used to happen, silently, every time a listener moved the audio to
// their phone mid-Vibe.
//
// No foreign key on purpose: a report from the owning device carries the
// whole session, and losing that to a session row that has since been
// deleted would cost far more than a dangling id.
id: '20260810_playback_state_vibe_session',
sql: `
ALTER TABLE playback_state
ADD COLUMN IF NOT EXISTS vibe_session_id UUID;
`,
},
{
id: '20260810_rehide_resurrected_dislikes',
sql: `
UPDATE tracks t
SET state = 'HIDDEN'
FROM dislikes d
WHERE d.track_id = t.id
AND d.deleted_at IS NULL
AND t.state IN ('LIBRARY', 'RECOMMENDED');
`,
},
];
+185 -2
View File
@@ -130,6 +130,17 @@ CREATE TABLE IF NOT EXISTS tracks (
-- Add normalized columns as generated columns for existing databases where the
-- CREATE TABLE IF NOT EXISTS above was a no-op (column didn't exist before).
-- `albums.release_date` was introduced after the original albums table. It
-- must exist before the track synchronisation trigger below is compiled.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'albums' AND column_name = 'release_date'
) THEN
ALTER TABLE albums ADD COLUMN release_date DATE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
@@ -140,6 +151,49 @@ DO $$ BEGIN
END $$;
CREATE INDEX IF NOT EXISTS idx_tracks_release_date ON tracks (release_date) WHERE release_date IS NOT NULL;
-- An album's MusicBrainz first-release-date is the canonical date for every
-- track on that album. Keep the denormalised tracks.release_date column in
-- lockstep so date-based recommendation queries stay indexable and never have
-- to guess which of two conflicting values is authoritative.
--
-- The trigger intentionally also overwrites a direct tracks.release_date
-- update. There is no per-recording release-date provenance in this schema;
-- accepting an independent track value would silently make novelty results
-- depend on write order. A future per-recording metadata source needs its own
-- canonical/provenance column before changing this rule.
CREATE OR REPLACE FUNCTION sync_track_release_date_from_album()
RETURNS TRIGGER AS $$
BEGIN
SELECT release_date INTO NEW.release_date
FROM albums
WHERE id = NEW.album_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_tracks_sync_release_date ON tracks;
CREATE TRIGGER trg_tracks_sync_release_date
BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks
FOR EACH ROW EXECUTE FUNCTION sync_track_release_date_from_album();
CREATE OR REPLACE FUNCTION propagate_album_release_date_to_tracks()
RETURNS TRIGGER AS $$
BEGIN
UPDATE tracks
SET release_date = NEW.release_date
WHERE album_id = NEW.id
AND release_date IS DISTINCT FROM NEW.release_date;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_albums_propagate_release_date ON albums;
CREATE TRIGGER trg_albums_propagate_release_date
AFTER UPDATE OF release_date ON albums
FOR EACH ROW
WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)
EXECUTE FUNCTION propagate_album_release_date_to_tracks();
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
@@ -248,12 +302,20 @@ CREATE TABLE IF NOT EXISTS recommendation_batch_track (
-- "Success-Driven Center" rule (a completed play moves the active batch's center)
-- and the feedback learning loop. Created with IF NOT EXISTS so it can be
-- self-provisioned on databases that predate this schema change.
-- A play is a historical fact: it stays true after the file is gone. Hence
-- ON DELETE SET NULL rather than CASCADE, plus the denormalised title/artist so
-- a row whose track was hard-deleted is still readable. listened_ms is what was
-- actually heard, recorded at play time; tracks.duration is not a substitute
-- because it disappears with the track.
CREATE TABLE IF NOT EXISTS play_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
batch_id UUID REFERENCES recommendation_batch(id) ON DELETE SET NULL,
completed BOOLEAN NOT NULL DEFAULT false,
listened_ms INTEGER,
track_title TEXT,
track_artist TEXT,
played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
@@ -306,12 +368,20 @@ CREATE TABLE IF NOT EXISTS track_audio_features (
key TEXT,
energy REAL,
danceability REAL,
-- Reserved legacy columns: Vibe readers tolerate these as NULL. The local
-- analyzer intentionally does not claim to infer them.
valence REAL,
acousticness REAL,
instrumentalness REAL,
liveness REAL,
valence_score REAL,
tempo REAL
tempo REAL,
analysis_version SMALLINT NOT NULL DEFAULT 0,
source_hash TEXT,
analyzed_at TIMESTAMPTZ,
CONSTRAINT track_audio_features_bpm_range CHECK (bpm IS NULL OR (bpm >= 30 AND bpm <= 300)),
CONSTRAINT track_audio_features_energy_range CHECK (energy IS NULL OR (energy >= 0 AND energy <= 1)),
CONSTRAINT track_audio_features_danceability_range CHECK (danceability IS NULL OR (danceability >= 0 AND danceability <= 1))
);
CREATE TABLE IF NOT EXISTS track_lyrics (
@@ -331,6 +401,10 @@ CREATE TABLE IF NOT EXISTS settings (
);
INSERT INTO settings (key, value) VALUES ('enrich_metadata', 'true') ON CONFLICT (key) DO NOTHING;
-- Artist portraits are independent from structural metadata. Keeping this
-- separate lets an operator re-fill artwork without re-running MusicBrainz
-- canonicalisation across the entire library.
INSERT INTO settings (key, value) VALUES ('enrich_artist_images', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_cover_art', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_genres', 'true') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('enrich_lyrics', 'true') ON CONFLICT (key) DO NOTHING;
@@ -355,6 +429,10 @@ INSERT INTO source_trust (key, trust, description) VALUES
('cover_art_archive', 0.85, 'Cover Art Archive, MB-backed.'),
('discogs', 0.75, 'Discogs release/artist credits.'),
('lastfm', 0.50, 'Last.fm tags + similar. Noisy; used as weak signal.'),
-- A first-party record of how a candidate was reached through the graph.
-- This is deliberately separate from Last.fm/MB: it describes the
-- traversal strategy, not a claim made by an external provider.
('graph_exploration', 0.40, 'Muzick graph traversal provenance for discovery candidates.'),
('listener_behavior', 0.40, 'Derived from observed play patterns. User-keyed.'),
('tag', 0.30, 'File-tag-derived via scanner heuristic. Lowest trust.')
ON CONFLICT (key) DO NOTHING;
@@ -455,6 +533,101 @@ CREATE TABLE IF NOT EXISTS session_state (
CREATE INDEX IF NOT EXISTS idx_session_state_user ON session_state (user_id, last_interaction DESC);
-- Durable Vibe v2 session ledger. session_state remains a rebuildable cache for
-- the existing director; these tables are the authoritative record for the
-- next-generation, versioned planner.
CREATE TABLE IF NOT EXISTS vibe_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')),
seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
context JSONB NOT NULL DEFAULT '{}'::jsonb,
policy_version TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
ON vibe_sessions (user_id, last_event_at DESC);
-- Compact, derived memory for the session director. Fingerprints are a
-- deliberately lossy description of session shape (not a track list) and are
-- used only as a soft freshness signal against recent sessions.
CREATE TABLE IF NOT EXISTS vibe_session_profiles (
session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb,
goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb,
exploration_coefficient REAL NOT NULL DEFAULT 0.30
CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1),
discovery_radius REAL NOT NULL DEFAULT 0.38
CHECK (discovery_radius >= 0 AND discovery_radius <= 1),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated
ON vibe_session_profiles (user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS vibe_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_event_id UUID,
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
position_ms INTEGER,
duration_ms INTEGER,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (session_id, client_event_id)
);
CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred
ON vibe_events (session_id, occurred_at);
CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred
ON vibe_events (user_id, occurred_at DESC);
-- Exactly-once projection marker for material Vibe feedback. The immutable
-- event remains authoritative; this row proves its effect was applied to the
-- listener inputs without double-counting an idempotent client retry.
CREATE TABLE IF NOT EXISTS vibe_event_projections (
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Separate from listener-belief projection because exploration is session
-- state. It lets a failed post-event replan safely retry the exact same
-- adaptation without counting the feedback twice.
CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections (
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS vibe_plan_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
version INTEGER NOT NULL CHECK (version > 0),
reason TEXT NOT NULL,
state_snapshot JSONB NOT NULL,
objective_snapshot JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (session_id, version)
);
CREATE TABLE IF NOT EXISTS vibe_plan_items (
plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
slot_role TEXT,
candidate_source TEXT NOT NULL,
score REAL NOT NULL,
score_breakdown JSONB NOT NULL,
explanation JSONB NOT NULL,
committed BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (plan_version_id, ordinal),
UNIQUE (plan_version_id, track_id)
);
-- Diversity budgets for the session director's planner.
CREATE TABLE IF NOT EXISTS diversity_budgets (
user_id UUID NOT NULL,
@@ -487,9 +660,19 @@ CREATE TABLE IF NOT EXISTS discovery_candidates (
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_eval_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'candidate',
-- Filled only after a worker scanned a successfully acquired file. Keeping
-- this FK makes candidate -> local-track provenance auditable and avoids
-- guessing from filename metadata later.
acquired_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
acquired_at TIMESTAMPTZ,
acquisition_attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
UNIQUE (source, external_id)
);
CREATE INDEX IF NOT EXISTS idx_discovery_candidates_status
ON discovery_candidates (status, first_seen_at);
-- Probation status for acquired tracks.
DO $$ BEGIN
IF NOT EXISTS (
+105
View File
@@ -16,6 +16,8 @@ export interface Album {
artist_id: string;
title: string;
year?: number | null;
/** Canonical MusicBrainz release-group first-release-date (YYYY-MM-DD). */
release_date?: string | null;
artwork_id?: string | null;
}
@@ -38,6 +40,8 @@ export interface Track {
skip_count: number;
dislike_count: number;
last_played_at?: Date | null;
/** Denormalised from albums.release_date by a database trigger. */
release_date?: string | null;
mtime?: number | null;
source_type: string;
artists?: TrackArtist[];
@@ -136,6 +140,30 @@ export interface ListenerBelief {
last_decayed_at: Date;
}
/**
* Stable pseudo-entity IDs for audio preference buckets. listener_beliefs uses
* UUID entity IDs for every entity type, while audio dimensions are values rather
* than rows in their own table. Keeping the IDs fixed makes these beliefs usable
* by the planner without introducing a second, unbounded vocabulary.
*/
export const AUDIO_PREFERENCE_BUCKETS = {
energy: {
low: '10000000-0000-0000-0000-000000000001',
medium: '10000000-0000-0000-0000-000000000002',
high: '10000000-0000-0000-0000-000000000003',
},
bpm: {
slow: '10000000-0000-0000-0000-000000000011',
medium: '10000000-0000-0000-0000-000000000012',
fast: '10000000-0000-0000-0000-000000000013',
},
valence: {
low: '10000000-0000-0000-0000-000000000021',
neutral: '10000000-0000-0000-0000-000000000022',
high: '10000000-0000-0000-0000-000000000023',
},
} as const;
export interface ClaimEdge {
subjectType: string;
subjectId: string;
@@ -166,3 +194,80 @@ export interface RepetitionRule {
dimension: string;
min_distance: number;
}
// ---------------------------------------------------------------------------
// Vibe v2 durable session ledger
// ---------------------------------------------------------------------------
export const VIBE_SESSION_STATUSES = ['active', 'paused', 'ended', 'expired', 'replaced'] as const;
export type VibeSessionStatus = (typeof VIBE_SESSION_STATUSES)[number];
export interface VibeSession {
id: string;
user_id: string;
status: VibeSessionStatus;
seed_track_id: string | null;
context: Record<string, unknown>;
policy_version: string;
started_at: Date;
last_event_at: Date;
ended_at: Date | null;
}
export interface VibeEvent {
id: string;
client_event_id: string | null;
session_id: string;
user_id: string;
track_id: string | null;
type: string;
occurred_at: Date;
position_ms: number | null;
duration_ms: number | null;
payload: Record<string, unknown>;
}
export interface RecordedVibeEvent {
event: VibeEvent;
/** False when a retried client_event_id returned the original event. */
inserted: boolean;
}
export interface VibePlanVersion {
id: string;
session_id: string;
version: number;
reason: string;
state_snapshot: Record<string, unknown>;
objective_snapshot: Record<string, unknown>;
created_at: Date;
}
export interface VibePlanItem {
plan_version_id: string;
ordinal: number;
track_id: string;
slot_role: string | null;
candidate_source: string;
score: number;
score_breakdown: Record<string, unknown>;
explanation: unknown;
committed: boolean;
}
export interface VibePlan extends VibePlanVersion {
items: VibePlanItem[];
}
/** Derived session-director memory. The ledger remains authoritative; this
* compact row makes fingerprints, bounded goals, and exploration state cheap
* to read while planning. */
export interface VibeSessionProfile {
session_id: string;
user_id: string;
fingerprint: Record<string, unknown>;
goals: Record<string, unknown>;
exploration_coefficient: number;
discovery_radius: number;
updated_at: Date;
}
+260 -10
View File
@@ -2,6 +2,87 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { JobService } from '../services/job.service.js';
import { DbService } from '../services/db.service.js';
const ENRICHMENT_SETTING_KEYS = [
'enrich_metadata',
'enrich_artist_images',
'enrich_cover_art',
'enrich_genres',
'enrich_lyrics',
'enrich_artist_similarity',
'enrich_audio_analysis',
] as const;
type ReenrichmentScope = {
metadata?: boolean;
artistImages?: boolean;
albumCovers?: boolean;
};
type ReenrichmentRequest = {
/** Preview by default. Jobs are only added with an explicit confirmation. */
confirm?: boolean;
/** Missing-only is the safe default; false means refresh the selected scope. */
missingOnly?: boolean;
/** Per-scope batch cap. Defaults to 250 and never exceeds 1,000. */
limit?: number;
scope?: ReenrichmentScope;
};
function getReenrichmentOptions(body: ReenrichmentRequest = {}) {
const requestedScope = body.scope ?? {};
const scope = {
metadata: requestedScope.metadata ?? true,
artistImages: requestedScope.artistImages ?? true,
albumCovers: requestedScope.albumCovers ?? true,
};
const rawLimit = Number(body.limit ?? 250);
const limit = Number.isInteger(rawLimit) ? Math.max(1, Math.min(rawLimit, 1000)) : 250;
return { confirm: body.confirm === true, missingOnly: body.missingOnly !== false, limit, scope };
}
async function getReenrichmentStatus(dbService: DbService, jobService: JobService) {
const db = dbService.pgClient;
const [settingsRes, coverageRes, queue] = await Promise.all([
db.query<{ key: string; value: string }>(
`SELECT key, value FROM settings WHERE key = ANY($1::text[])`,
[ENRICHMENT_SETTING_KEYS],
),
db.query<{
library_tracks: number;
tracks_without_release_date: number;
tracks_without_album_mbid: number;
artists_total: number;
artists_without_image: number;
artists_without_mbid: number;
albums_total: number;
albums_without_artwork: number;
albums_without_release_date: number;
}>(`
SELECT
COUNT(*) FILTER (WHERE t.state = 'LIBRARY')::int AS library_tracks,
COUNT(*) FILTER (WHERE t.state = 'LIBRARY' AND t.release_date IS NULL)::int AS tracks_without_release_date,
COUNT(*) FILTER (WHERE t.state = 'LIBRARY' AND al.mbid IS NULL)::int AS tracks_without_album_mbid,
(SELECT COUNT(*)::int FROM artists) AS artists_total,
(SELECT COUNT(*)::int FROM artists WHERE image_path IS NULL OR image_path = '') AS artists_without_image,
(SELECT COUNT(*)::int FROM artists WHERE mbid IS NULL) AS artists_without_mbid,
(SELECT COUNT(*)::int FROM albums) AS albums_total,
(SELECT COUNT(*)::int FROM albums WHERE artwork_id IS NULL OR artwork_id = '') AS albums_without_artwork,
(SELECT COUNT(*)::int FROM albums WHERE release_date IS NULL) AS albums_without_release_date
FROM tracks t
LEFT JOIN albums al ON al.id = t.album_id
`),
jobService.getEnrichmentQueueDiagnostics(),
]);
const settings = Object.fromEntries(
ENRICHMENT_SETTING_KEYS.map((key) => [
key,
settingsRes.rows.find((row) => row.key === key)?.value === 'true',
]),
) as Record<(typeof ENRICHMENT_SETTING_KEYS)[number], boolean>;
return { settings, coverage: coverageRes.rows[0], queue };
}
export default async function adminRoutes(fastify: FastifyInstance, options: { jobService: JobService; dbService: DbService }) {
const { jobService, dbService } = options;
@@ -24,6 +105,60 @@ export default async function adminRoutes(fastify: FastifyInstance, options: { j
return { status: 'Artist reprocessing job enqueued' };
});
/** Rebuild artist/genre/audio beliefs from durable history after a model upgrade. */
fastify.post('/vibe/rebuild-beliefs', async (request: FastifyRequest, reply: FastifyReply) => {
const { userId } = (request.body ?? {}) as { userId?: string };
const resolvedUserId = userId || (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const result = await dbService.rebuildDerivedListenerBeliefs(resolvedUserId);
return reply.send({ status: 'rebuilt', userId: resolvedUserId, ...result });
});
/**
* Attach a human/resolver-vetted source to a System E candidate. This is an
* admin-only hand-off: graph traversal identifies an artist/path, but must
* never turn that into an arbitrary web search and download. The worker still
* applies its own host allow-list immediately before invoking yt-dlp.
*/
fastify.post('/discovery/candidates/:id/acquisition-source', async (request, reply) => {
const { id } = request.params as { id: string };
const body = request.body as { url?: string; expectedTitle?: string; expectedArtist?: string };
if (!body?.url || typeof body.url !== 'string' || body.url.length > 4000) {
return reply.code(400).send({ error: 'HTTPS url is required' });
}
let url: URL;
try {
url = new URL(body.url);
} catch {
return reply.code(400).send({ error: 'url is invalid' });
}
if (url.protocol !== 'https:' || url.username || url.password) {
return reply.code(400).send({ error: 'url must be credential-free HTTPS' });
}
const source = {
url: url.toString(),
...(typeof body.expectedTitle === 'string' ? { expectedTitle: body.expectedTitle.slice(0, 500) } : {}),
...(typeof body.expectedArtist === 'string' ? { expectedArtist: body.expectedArtist.slice(0, 500) } : {}),
};
try {
const result = await dbService.pgClient.query(
`UPDATE discovery_candidates
SET notes = jsonb_set(COALESCE(notes, '{}'::jsonb), '{acquisition}', $2::jsonb, true),
status = 'candidate', last_eval_at = NULL, last_error = NULL
WHERE id = $1::uuid
AND status IN ('candidate', 'awaiting_resolution', 'acquisition_disabled', 'failed')
RETURNING id, status`,
[id, JSON.stringify(source)]
);
if (result.rows.length === 0) {
return reply.code(404).send({ error: 'candidate does not exist or cannot be re-queued' });
}
return reply.send({ candidate: result.rows[0] });
} catch (err) {
const message = err instanceof Error ? err.message : 'could not attach acquisition source';
return reply.code(400).send({ error: message });
}
});
fastify.post('/dedup-albums', async (_request: FastifyRequest, reply: FastifyReply) => {
// Merge duplicate album rows directly (synchronous — it's just SQL, no
// external API calls). Returns the number of albums merged away.
@@ -92,17 +227,132 @@ export default async function adminRoutes(fastify: FastifyInstance, options: { j
return { status: 'Albums deduplicated', merged: res.rows[0]?.count ?? 0 };
});
/**
* Return coverage, active toggles, queued work, and recent worker failures.
* This is deliberately separate from enqueueing so an operator can diagnose
* a disabled provider or a failing worker before launching another batch.
*/
fastify.get('/reenrichment/status', async () => {
return getReenrichmentStatus(dbService, jobService);
});
/**
* Safe re-enrichment control plane. A call is a preview unless confirm=true;
* the default is a 250-entity, missing-only batch. The three job types remain
* independent so artwork work is not hidden behind track metadata work.
*/
fastify.post<{ Body: ReenrichmentRequest }>('/reenrichment', async (request, reply) => {
const options = getReenrichmentOptions(request.body);
const status = await getReenrichmentStatus(dbService, jobService);
const blockedBySettings: Partial<Record<keyof ReenrichmentScope, string>> = {};
const metadataEnabled = status.settings.enrich_metadata
|| status.settings.enrich_genres
|| status.settings.enrich_lyrics;
if (options.scope.metadata && !metadataEnabled) {
blockedBySettings.metadata = 'All track enrichment toggles are disabled.';
}
if (options.scope.artistImages && !status.settings.enrich_artist_images) {
blockedBySettings.artistImages = 'enrich_artist_images is disabled.';
}
if (options.scope.albumCovers && !status.settings.enrich_cover_art) {
blockedBySettings.albumCovers = 'enrich_cover_art is disabled.';
}
const db = dbService.pgClient;
const [tracks, artists, albums] = await Promise.all([
options.scope.metadata && !blockedBySettings.metadata
? db.query<{ id: string }>(
`SELECT t.id
FROM tracks t
LEFT JOIN albums al ON al.id = t.album_id
LEFT JOIN track_artists ta ON ta.track_id = t.id AND ta.role = 'main'
LEFT JOIN artists ar ON ar.id = ta.artist_id
WHERE t.state = 'LIBRARY'
AND ($1::boolean = false OR t.release_date IS NULL OR al.mbid IS NULL OR ar.mbid IS NULL)
ORDER BY t.id
LIMIT $2`,
[options.missingOnly, options.limit],
)
: Promise.resolve({ rows: [] as Array<{ id: string }> }),
options.scope.artistImages && !blockedBySettings.artistImages
? db.query<{ id: string }>(
`SELECT id FROM artists
WHERE $1::boolean = false OR image_path IS NULL OR image_path = ''
ORDER BY id
LIMIT $2`,
[options.missingOnly, options.limit],
)
: Promise.resolve({ rows: [] as Array<{ id: string }> }),
options.scope.albumCovers && !blockedBySettings.albumCovers
? db.query<{ id: string }>(
`SELECT id FROM albums
WHERE $1::boolean = false OR artwork_id IS NULL OR artwork_id = ''
ORDER BY id
LIMIT $2`,
[options.missingOnly, options.limit],
)
: Promise.resolve({ rows: [] as Array<{ id: string }> }),
]);
const plan = {
metadataTrackIds: tracks.rows.map((row) => row.id),
artistIds: artists.rows.map((row) => row.id),
albumIds: albums.rows.map((row) => row.id),
};
const wouldQueue = {
metadata: plan.metadataTrackIds.length,
artistImages: plan.artistIds.length,
albumCovers: plan.albumIds.length,
};
if (!options.confirm) {
return {
status: 'preview',
message: 'No jobs were queued. Repeat with confirm=true to enqueue this bounded plan.',
options,
blockedBySettings,
wouldQueue,
diagnostics: status,
};
}
if (Object.keys(blockedBySettings).length === 3 ||
(wouldQueue.metadata + wouldQueue.artistImages + wouldQueue.albumCovers === 0)) {
return reply.code(409).send({
status: 'not_queued',
message: Object.keys(blockedBySettings).length === 3
? 'Every selected scope is disabled by enrichment settings.'
: 'No eligible entities matched this re-enrichment batch.',
options,
blockedBySettings,
wouldQueue,
diagnostics: status,
});
}
const queued = await jobService.enqueueReenrichment(plan);
const enqueueFailures = queued.metadata.failed.length
+ queued.artistImages.failed.length
+ queued.albumCovers.failed.length;
return {
status: enqueueFailures === 0 ? 'queued' : 'partially_queued',
message: enqueueFailures === 0
? 'Re-enrichment jobs were queued. Check /reenrichment/status and job history for outcomes.'
: 'Some jobs could not be queued; see queued.*.failed for exact errors.',
options,
blockedBySettings,
queued,
diagnostics: await getReenrichmentStatus(dbService, jobService),
};
});
// The prior endpoint queued every track and implied cover/image work that it
// never scheduled. Keep the failure explicit instead of silently claiming a
// successful library-wide refresh.
fastify.post('/reenrich-tracks', async (_request: FastifyRequest, reply: FastifyReply) => {
// Re-enqueue metadata_refresh for every LIBRARY track without re-reading
// files from disk. This re-runs the MusicBrainz canonicalisation (artist
// names, album titles, MBIDs) and re-triggers album_cover jobs — much
// faster than a full scan when only metadata needs refreshing.
const res = await dbService.pgClient.query<{ id: string }>(
`SELECT id FROM tracks WHERE state = 'LIBRARY' ORDER BY id`
);
const trackIds = res.rows.map((r) => r.id);
const enqueued = await jobService.enqueueMetadataRefreshBatch(trackIds);
return { status: 'Re-enrich enqueued', trackCount: enqueued };
return reply.code(410).send({
error: 'Deprecated endpoint. Use POST /admin/reenrichment (preview first; confirm=true to queue).',
});
});
fastify.get('/queue-stats', async () => {
+62 -1
View File
@@ -2,9 +2,11 @@ import { FastifyInstance } from 'fastify';
import { DbService } from '../services/db.service.js';
import { DiscoveryService } from '../services/discovery.service.js';
import { ImageEnrichmentService } from '../services/image-enrichment.service.js';
import { JobService } from '../services/job.service.js';
export default async function discoveryRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
export default async function discoveryRoutes(fastify: FastifyInstance, options: { dbService: DbService; jobService: JobService }) {
const { dbService } = options;
const { jobService } = options;
const discovery = new DiscoveryService(dbService);
const images = new ImageEnrichmentService(dbService);
@@ -33,12 +35,71 @@ export default async function discoveryRoutes(fastify: FastifyInstance, options:
return reply.send({ candidates: res.rows });
});
/**
* GET /api/discovery/overview — every recommendation and what became of it.
*
* One row per candidate, acquired or not, so a stalled candidate is as
* visible as a retained track. The play/skip counts come from `evidence`
* rather than tracks.play_count because the probation sweep decides on
* exactly those two signals: showing anything else would be showing a number
* that does not drive the outcome.
*/
fastify.get('/discovery/overview', async (request, reply) => {
const query = request.query as { limit?: string };
const limit = Math.min(Math.max(parseInt(query.limit || '200', 10) || 200, 1), 500);
const rows = await dbService.pgClient.query(
`SELECT dc.id, dc.source, dc.status, dc.title, dc.artist_credit, dc.notes,
dc.first_seen_at, dc.acquired_at, dc.last_error, dc.acquisition_attempts,
t.id AS track_id, t.title AS track_title, t.artist AS track_artist,
t.probation_status, t.probation_entered_at,
(SELECT COUNT(*)::int FROM evidence e
WHERE e.entity_type = 'track' AND e.entity_id = t.id
AND e.signal = 'playback_completed') AS completed_plays,
(SELECT COUNT(*)::int FROM evidence e
WHERE e.entity_type = 'track' AND e.entity_id = t.id
AND e.signal = 'skip_quick') AS quick_skips
FROM discovery_candidates dc
LEFT JOIN tracks t ON t.id = dc.acquired_track_id
ORDER BY COALESCE(dc.acquired_at, dc.first_seen_at) DESC
LIMIT $1`,
[limit]
);
const summary = await dbService.pgClient.query(
`SELECT dc.source,
COUNT(*)::int AS candidates,
COUNT(t.id) FILTER (WHERE t.probation_status = 'probation')::int AS probation,
COUNT(t.id) FILTER (WHERE t.probation_status = 'retained')::int AS retained,
COUNT(t.id) FILTER (WHERE t.probation_status = 'retired')::int AS retired,
COUNT(*) FILTER (WHERE dc.acquired_track_id IS NULL
AND dc.status <> 'candidate')::int AS stalled
FROM discovery_candidates dc
LEFT JOIN tracks t ON t.id = dc.acquired_track_id
GROUP BY dc.source
ORDER BY dc.source`
);
return reply.send({ rows: rows.rows, summary: summary.rows });
});
/**
* POST /api/discovery/eval — evaluate pending candidates for acquisition
*/
fastify.post('/discovery/eval', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const results = await discovery.evalCandidates(userId);
for (const result of results) {
if (!result.shouldAcquire) continue;
try {
await jobService.enqueueDiscoveryAcquisition(result.candidateId);
} catch (err) {
const reason = err instanceof Error ? err.message : 'failed to enqueue acquisition';
await discovery.markEnqueueFailed(result.candidateId, reason);
result.shouldAcquire = false;
result.reason = `queue unavailable: ${reason}`;
}
}
return reply.send({ evaluated: results.length, results });
});
+9 -2
View File
@@ -6,16 +6,23 @@ export default async function historyRoutes(fastify: FastifyInstance, options: {
// Record a playback event. completed defaults to false.
fastify.post('/history', async (request, reply) => {
const { trackId, completed, batchId } = request.body as {
const { trackId, completed, batchId, listenedMs } = request.body as {
trackId: string;
completed?: boolean;
batchId?: string;
listenedMs?: number;
};
if (!trackId) {
return reply.code(400).send({ error: 'trackId is required' });
}
// Clamped rather than rejected: a bogus duration must not cost the caller a
// play record, and a 24h ceiling keeps one bad client from dominating any
// listening-time total.
const listened = typeof listenedMs === 'number' && Number.isFinite(listenedMs)
? Math.max(0, Math.min(Math.round(listenedMs), 24 * 60 * 60 * 1000))
: undefined;
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const historyId = await dbService.recordPlay(userId, trackId, completed === true, batchId);
const historyId = await dbService.recordPlay(userId, trackId, completed === true, batchId, listened);
return reply.send({ historyId });
});
+12
View File
@@ -10,12 +10,24 @@ const ALLOWED_HOSTS = new Set([
'images.genius.com',
'commons.wikimedia.org',
'e.snmc.io',
// Cover Art Archive 302s to the apex host, which then redirects on to an
// ia*.us.archive.org node. The suffix below covers the node, not the apex.
'archive.org',
]);
// Wildcard suffixes — any subdomain of these is allowed.
//
// The CDNs below are where enrichment actually stores artwork: of 572 albums
// with a cover, 126 sit on mzstatic (iTunes), 115 on dzcdn (Deezer) and 26 on
// discogs, and every coverartarchive.org URL 302s to an ia*.us.archive.org
// node. Without these the proxy answered 403 for every cover in the library.
const ALLOWED_SUFFIXES = [
'.coverartarchive.org',
'.musicbrainz.org',
'.archive.org',
'.mzstatic.com',
'.dzcdn.net',
'.discogs.com',
];
function isAllowed(hostname: string): boolean {
+11
View File
@@ -16,6 +16,13 @@ export default async function libraryRoutes(fastify: FastifyInstance, options: {
return tracks;
});
// Seeds for the Vibe start screen. Static path, so it is matched ahead of
// /tracks/:trackId.
fastify.get('/tracks/seeds', async (request) => {
const query = request.query as any;
return await dbService.getSeedTracks(query.limit ? parseInt(query.limit) : undefined);
});
fastify.get('/artists', async (request) => {
const query = request.query as any;
return await dbService.getArtists({
@@ -59,6 +66,10 @@ export default async function libraryRoutes(fastify: FastifyInstance, options: {
return album;
});
fastify.get('/library/stats', async () => {
return await dbService.getLibraryStats();
});
// Genres
fastify.get('/genres', async () => {
return await dbService.getGenres();
+235
View File
@@ -0,0 +1,235 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import {
NotSessionOwnerError,
PlaybackCommand,
PlaybackCommandType,
PlaybackStatePatch,
PlaybackSyncService,
} from '../services/playback-sync.service.js';
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const COMMAND_TYPES: PlaybackCommandType[] = ['play', 'pause', 'next', 'prev', 'seek', 'play_track'];
/** Well inside the 90s staleness window, and enough to keep proxies from closing an idle stream. */
const HEARTBEAT_MS = 25_000;
type Body = Record<string, unknown>;
function isObject(value: unknown): value is Body {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function userIdFrom(request: FastifyRequest): string {
const header = request.headers['x-user-id'];
return typeof header === 'string' && UUID_RE.test(header) ? header : DEFAULT_USER_ID;
}
function parsePatch(body: unknown): PlaybackStatePatch | { error: string } {
const input = isObject(body) ? body : {};
const patch: PlaybackStatePatch = {};
if (input.trackId !== undefined) {
if (input.trackId !== null && !(typeof input.trackId === 'string' && UUID_RE.test(input.trackId))) {
return { error: 'trackId must be a UUID or null' };
}
patch.trackId = input.trackId as string | null;
}
if (input.vibeSessionId !== undefined) {
if (input.vibeSessionId !== null && !(typeof input.vibeSessionId === 'string' && UUID_RE.test(input.vibeSessionId))) {
return { error: 'vibeSessionId must be a UUID or null' };
}
patch.vibeSessionId = input.vibeSessionId as string | null;
}
if (input.queue !== undefined) {
if (!Array.isArray(input.queue)) return { error: 'queue must be an array' };
patch.queue = input.queue;
}
if (input.queueIndex !== undefined) {
if (typeof input.queueIndex !== 'number' || !Number.isInteger(input.queueIndex)) {
return { error: 'queueIndex must be an integer' };
}
patch.queueIndex = input.queueIndex;
}
if (input.position !== undefined) {
if (typeof input.position !== 'number' || !Number.isFinite(input.position) || input.position < 0) {
return { error: 'position must be a non-negative number of seconds' };
}
patch.position = input.position;
}
if (input.isPlaying !== undefined) {
if (typeof input.isPlaying !== 'boolean') return { error: 'isPlaying must be a boolean' };
patch.isPlaying = input.isPlaying;
}
return patch;
}
function parseCommand(body: unknown): PlaybackCommand | { error: string } {
const input = isObject(body) ? body : {};
const type = input.type;
if (typeof type !== 'string' || !COMMAND_TYPES.includes(type as PlaybackCommandType)) {
return { error: `type must be one of ${COMMAND_TYPES.join(', ')}` };
}
const command: PlaybackCommand = { type: type as PlaybackCommandType };
if (type === 'seek') {
if (typeof input.position !== 'number' || !Number.isFinite(input.position) || input.position < 0) {
return { error: 'seek requires a non-negative position in seconds' };
}
command.position = input.position;
}
if (type === 'play_track') {
if (typeof input.trackId !== 'string' || !UUID_RE.test(input.trackId)) {
return { error: 'play_track requires a trackId' };
}
command.trackId = input.trackId;
}
return command;
}
export default async function playbackRoutes(
fastify: FastifyInstance,
options: { playbackSync: PlaybackSyncService }
) {
const { playbackSync } = options;
fastify.post('/playback/devices', async (request, reply) => {
const input = isObject(request.body) ? request.body : {};
const name = typeof input.name === 'string' ? input.name : '';
const deviceId = typeof input.deviceId === 'string' && UUID_RE.test(input.deviceId) ? input.deviceId : null;
const device = await playbackSync.registerDevice(userIdFrom(request), name, deviceId);
return reply.code(200).send(device);
});
fastify.get('/playback/devices', async (request, reply) => {
return reply.code(200).send({ devices: await playbackSync.listDevices(userIdFrom(request)) });
});
fastify.get('/playback/state', async (request, reply) => {
const userId = userIdFrom(request);
const [state, devices] = await Promise.all([
playbackSync.getState(userId),
playbackSync.listDevices(userId),
]);
return reply.code(200).send({ state, devices });
});
fastify.post('/playback/state', async (request, reply) => {
const input = isObject(request.body) ? request.body : {};
const deviceId = input.deviceId;
if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
return reply.code(400).send({ error: 'deviceId must be a UUID' });
}
const patch = parsePatch(input);
if ('error' in patch) return reply.code(400).send({ error: patch.error });
try {
const state = await playbackSync.reportState(userIdFrom(request), deviceId, patch);
return reply.code(200).send({ state });
} catch (err) {
if (err instanceof NotSessionOwnerError) {
// 409, not 403: the device is allowed here, it is simply no longer the
// one holding the audio, and its own state report is the stale thing.
return reply.code(409).send({ error: err.message });
}
throw err;
}
});
fastify.post('/playback/command', async (request, reply) => {
const command = parseCommand(request.body);
if ('error' in command) return reply.code(400).send({ error: command.error });
const result = await playbackSync.sendCommand(userIdFrom(request), command);
if (!result.deliveredTo) return reply.code(409).send({ error: 'no device is holding playback' });
return reply.code(202).send(result);
});
fastify.post('/playback/transfer', async (request, reply) => {
const input = isObject(request.body) ? request.body : {};
const deviceId = input.deviceId;
if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
return reply.code(400).send({ error: 'deviceId must be a UUID' });
}
try {
return reply.code(200).send({ state: await playbackSync.transfer(userIdFrom(request), deviceId) });
} catch {
return reply.code(404).send({ error: 'unknown device' });
}
});
fastify.post('/playback/release', async (request, reply) => {
const input = isObject(request.body) ? request.body : {};
const deviceId = input.deviceId;
if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) {
return reply.code(400).send({ error: 'deviceId must be a UUID' });
}
await playbackSync.releaseIfOwner(userIdFrom(request), deviceId);
return reply.code(204).send();
});
/**
* The push channel. Every device holds one of these open: it receives the
* session snapshot on connect, every later change, and the commands aimed at
* it. The periodic comment line doubles as the device's liveness heartbeat,
* so an open stream is what "this device is online" means.
*/
fastify.get('/playback/stream', async (request, reply) => {
const userId = userIdFrom(request);
const query = request.query as { deviceId?: string };
const deviceId = typeof query.deviceId === 'string' && UUID_RE.test(query.deviceId) ? query.deviceId : null;
if (!deviceId) return reply.code(400).send({ error: 'deviceId must be a UUID' });
// The response is written straight to the socket and lives for as long as
// the tab does. Without this Fastify still believes it owes a reply and
// waits on a handler that resolves with nothing.
reply.hijack();
const releaseStream = playbackSync.claimStream(userId, deviceId);
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
// nginx buffers event streams into uselessness without this.
'X-Accel-Buffering': 'no',
});
const write = (payload: unknown) => {
reply.raw.write(`data: ${JSON.stringify(payload)}\n\n`);
};
const unsubscribe = playbackSync.subscribe(userId, (event) => {
if (event.type === 'command' && event.deviceId !== deviceId) return;
write(event);
});
// The reply is hijacked, so a throw from here reaches no error handler that
// could answer it. Close the stream instead and let the client reopen.
try {
const [state, devices] = await Promise.all([
playbackSync.getState(userId),
playbackSync.listDevices(userId),
]);
write({ type: 'state', state, devices });
} catch (err) {
request.log.error(err);
unsubscribe();
releaseStream();
reply.raw.end();
return;
}
const heartbeat = setInterval(() => {
reply.raw.write(': ping\n\n');
playbackSync.touchDevice(userId, deviceId).catch(() => {});
}, HEARTBEAT_MS);
request.raw.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
releaseStream();
// Ownership deliberately survives a closed stream. A phone changing cell,
// locking its screen or dozing drops this connection for a few seconds
// while its audio keeps playing; releasing here published an unowned
// session, which the phone then read as "something else took over" and
// paused itself. A device that is really gone loses the session two other
// ways: `pagehide` releases it outright, and the stale-device sweep frees
// an owner whose heartbeat has been silent past DEVICE_STALE_MS.
});
});
}
+1
View File
@@ -3,6 +3,7 @@ import { DbService } from '../services/db.service.js';
const SETTING_KEYS = [
'enrich_metadata',
'enrich_artist_images',
'enrich_cover_art',
'enrich_genres',
'enrich_lyrics',
+90 -12
View File
@@ -9,14 +9,36 @@ interface ActivePlan {
sessionId: string;
plan: Candidate[];
seedTrackId: string | null;
/** Tracks handed to the player during this Redis-backed session. */
servedTrackIds?: string[];
/** Explicit feedback targets (skip, dislike, completion, promotion). */
excludedTrackIds?: string[];
}
const PLAN_TTL_SEC = 2 * 3600;
// Retain enough history for long listening sessions without allowing an
// unbounded Redis value if a client leaves a session running for days.
const MAX_SESSION_EXCLUSIONS = 1000;
function planKey(userId: string): string {
return `v2:plan:${userId}`;
}
function appendUniqueId(ids: string[] | undefined, id: string): string[] {
const next = ids ? [...ids] : [];
if (!next.includes(id)) next.push(id);
return next.length > MAX_SESSION_EXCLUSIONS
? next.slice(next.length - MAX_SESSION_EXCLUSIONS)
: next;
}
function sessionExclusions(active: ActivePlan): string[] {
return [...new Set([
...(active.servedTrackIds ?? []),
...(active.excludedTrackIds ?? []),
])];
}
export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) {
const { dbService, sessionDirector: director } = options;
@@ -47,6 +69,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
// span multiple keys/services atomically, swap this for a WATCH/MULTI transaction
// or move the plan into a single Lua script instead of an app-level lock.
const RELEASE_LOCK_LUA = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
const RENEW_LOCK_LUA = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end`;
async function withPlanLock<T>(userId: string, fn: () => Promise<T>): Promise<T> {
const lockKey = `v2:planlock:${userId}`;
@@ -54,16 +77,20 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
const deadline = Date.now() + 5000;
let acquired = false;
while (Date.now() < deadline) {
const res = await redisClient.set(lockKey, token, { NX: true, PX: 3000 });
const res = await redisClient.set(lockKey, token, { NX: true, PX: 5000 });
if (res) { acquired = true; break; }
await new Promise((r) => setTimeout(r, 20 + Math.random() * 30));
}
if (!acquired) {
throw new Error('Timed out waiting for active-plan lock');
}
const renewal = setInterval(() => {
void redisClient.eval(RENEW_LOCK_LUA, { keys: [lockKey], arguments: [token, '5000'] });
}, 1500);
try {
return await fn();
} finally {
clearInterval(renewal);
await redisClient.eval(RELEASE_LOCK_LUA, { keys: [lockKey], arguments: [token] });
}
}
@@ -78,9 +105,18 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
const { seedTrackId } = request.body as { seedTrackId?: string };
const sessionId = await dbService.createSessionState(userId, undefined, { energy: 0.5, novelty_hunger: 0.3 });
const plan = await director.buildPlan(userId, sessionId, seedTrackId);
const initialExclusions = seedTrackId ? [seedTrackId] : [];
const plan = await director.buildPlan(userId, sessionId, seedTrackId, {
excludedTrackIds: initialExclusions,
});
await setActivePlan(userId, { sessionId, plan, seedTrackId: seedTrackId ?? null });
await setActivePlan(userId, {
sessionId,
plan,
seedTrackId: seedTrackId ?? null,
servedTrackIds: [],
excludedTrackIds: initialExclusions,
});
return reply.send({ sessionId, plan: plan.slice(0, 10) });
});
@@ -90,34 +126,62 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
*/
fastify.get('/v2/vibe/next', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const { sessionId } = request.query as { sessionId?: string };
const result = await withPlanLock(userId, async () => {
const active = await getActivePlan(userId);
if (!active || active.plan.length === 0) {
return null;
if (!active) {
return { kind: 'missing' as const };
}
if (!sessionId || active.sessionId !== sessionId) {
return { kind: 'replaced' as const };
}
if (active.plan.length === 0) {
return { kind: 'exhausted' as const };
}
const next = active.plan.shift()!;
active.servedTrackIds = appendUniqueId(active.servedTrackIds, next.trackId);
// Enrich with track details
const track = await dbService.getTrackById(next.trackId);
// Replan if running low
if (active.plan.length < 5) {
const refill = await director.replan(userId, active.sessionId, active.plan, [next.trackId], active.seedTrackId ?? undefined);
const refill = await director.replan(
userId,
active.sessionId,
active.plan,
[next.trackId],
active.seedTrackId ?? undefined,
{ excludedTrackIds: sessionExclusions(active) }
);
active.plan = refill;
}
await setActivePlan(userId, active);
return { track, explanation: next.explanation, planRemaining: active.plan.length };
return { kind: 'track' as const, track, explanation: next.explanation, planRemaining: active.plan.length };
});
if (!result) {
if (result.kind === 'missing') {
return reply.code(404).send({ error: 'No active plan. POST /api/v2/vibe/start first.' });
}
if (result.kind === 'replaced') {
return reply.code(409).send({ error: 'This Vibe session was replaced by a newer session.', code: 'VIBE_SESSION_REPLACED' });
}
if (result.kind === 'exhausted') {
return reply.code(409).send({
error: 'Vibe plan exhausted: no eligible unserved tracks remain for this session.',
code: 'VIBE_PLAN_EXHAUSTED',
});
}
return reply.send(result);
return reply.send({
track: result.track,
explanation: result.explanation,
planRemaining: result.planRemaining,
});
});
/**
@@ -126,11 +190,14 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
*/
fastify.post('/v2/vibe/feedback', async (request, reply) => {
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
const { trackId, action } = request.body as { trackId: string; action: string };
const { trackId, action, sessionId } = request.body as { trackId: string; action: string; sessionId?: string };
if (!trackId || !action) {
return reply.code(400).send({ error: 'trackId and action are required' });
}
if (!['completed', 'skipped', 'promoted', 'disliked'].includes(action)) {
return reply.code(400).send({ error: 'Unsupported Vibe feedback action' });
}
// Route to existing handlers for evidence wiring
if (action === 'completed') {
@@ -148,8 +215,19 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
const planRemaining = await withPlanLock(userId, async () => {
const active = await getActivePlan(userId);
if (!active) return 0;
const playedTrackIds = [trackId];
const refill = await director.replan(userId, active.sessionId, active.plan, playedTrackIds, active.seedTrackId ?? undefined);
if (!sessionId || active.sessionId !== sessionId) return 0;
// Feedback may race /next or arrive after a client-side prefetch. In all
// cases its track becomes ineligible for the rest of this session.
active.excludedTrackIds = appendUniqueId(active.excludedTrackIds, trackId);
const sessionTrackIds = sessionExclusions(active);
const refill = await director.replan(
userId,
active.sessionId,
active.plan,
[trackId],
active.seedTrackId ?? undefined,
{ excludedTrackIds: sessionTrackIds }
);
active.plan = refill;
await setActivePlan(userId, active);
return active.plan.length;
@@ -0,0 +1,201 @@
import Fastify from 'fastify';
import { describe, expect, it, vi } from 'vitest';
import vibeSessionsRoutes, { VibeIdentityResolver } from './vibe-sessions.routes.js';
import { VibeSessionLifecycleError } from '../services/vibe-session-coordinator.service.js';
const SESSION_ID = '11111111-1111-4111-8111-111111111111';
const TRACK_ID = '22222222-2222-4222-8222-222222222222';
const USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
function response() {
return {
sessionId: SESSION_ID, planVersion: 1, now: null, preview: [], state: {},
replanned: false, replanReason: null,
session: { id: SESSION_ID, status: 'active' },
};
}
async function appWithCoordinator(identityResolver?: VibeIdentityResolver) {
const coordinator = {
start: vi.fn().mockResolvedValue(response()),
getPlan: vi.fn().mockResolvedValue(response()),
appendEvent: vi.fn().mockResolvedValue({ ...response(), event: { id: 'event-1' }, idempotent: false }),
end: vi.fn().mockResolvedValue(response()),
serveNext: vi.fn().mockResolvedValue(response()),
advancePastUnplayable: vi.fn().mockResolvedValue(response()),
} as any;
const app = Fastify();
await app.register(vibeSessionsRoutes, { coordinator, ...(identityResolver ? { identityResolver } : {}) });
await app.ready();
return { app, coordinator };
}
describe('durable Vibe session routes', () => {
it('uses the caller identity so concurrent listeners receive separate sessions', async () => {
const { app, coordinator } = await appWithCoordinator();
const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': USER_ID }, payload: {} });
expect(result.statusCode).toBe(201);
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.any(Object));
await app.close();
});
it('uses the existing application default when no user header is provided', async () => {
const { app, coordinator } = await appWithCoordinator();
const result = await app.inject({
method: 'POST', url: '/v2/vibe/sessions', payload: {},
});
expect(result.statusCode).toBe(201);
expect(coordinator.start).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000000', expect.any(Object));
await app.close();
});
it('creates a session and validates event payloads before touching the coordinator', async () => {
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
const created = await app.inject({
method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': 'spoofed' },
payload: { seedTrackId: TRACK_ID },
});
const invalidEvent = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' },
payload: { type: 'definitely-not-an-event' },
});
expect(created.statusCode).toBe(201);
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ seedTrackId: TRACK_ID }));
expect(invalidEvent.statusCode).toBe(400);
expect(coordinator.appendEvent).not.toHaveBeenCalled();
await app.close();
});
it('accepts bounded local calendar context and rejects malformed context', async () => {
const { app, coordinator } = await appWithCoordinator();
const valid = await app.inject({
method: 'POST', url: '/v2/vibe/sessions',
payload: { context: { localHour: 22, weekday: 5, month: 8, timeZone: 'Europe/Samara' } },
});
const invalid = await app.inject({
method: 'POST', url: '/v2/vibe/sessions',
payload: { context: { localHour: 24, weekday: 5, month: 8 } },
});
expect(valid.statusCode).toBe(201);
expect(coordinator.start).toHaveBeenCalledWith(
'00000000-0000-0000-0000-000000000000',
expect.objectContaining({ context: { localHour: 22, weekday: 5, month: 8, timeZone: 'Europe/Samara' } }),
);
expect(invalid.statusCode).toBe(400);
await app.close();
});
it('rejects server-only event types from the client event ledger', async () => {
const { app, coordinator } = await appWithCoordinator();
const result = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`,
payload: {
type: 'track_served',
trackId: TRACK_ID,
payload: { planVersionId: '33333333-3333-4333-8333-333333333333', ordinal: 0 },
},
});
expect(result.statusCode).toBe(400);
expect(result.json()).toEqual({ error: 'type must be a supported client Vibe event type' });
expect(coordinator.appendEvent).not.toHaveBeenCalled();
await app.close();
});
it('returns a lifecycle conflict when an initial plan race ends or replaces the session', async () => {
const { app, coordinator } = await appWithCoordinator();
coordinator.start.mockRejectedValueOnce(
new VibeSessionLifecycleError('Cannot publish a plan for ended Vibe session'),
);
const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', payload: {} });
expect(result.statusCode).toBe(409);
expect(result.json()).toEqual({
error: 'Cannot publish a plan for ended Vibe session',
code: 'VIBE_SESSION_NOT_ACTIVE',
});
await app.close();
});
it('passes a requested plan revision and validated idempotent event through to the coordinator', async () => {
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
const plan = await app.inject({
method: 'GET', url: `/v2/vibe/sessions/${SESSION_ID}/plans?version=2`, headers: { 'x-user-id': 'spoofed' },
});
const event = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' },
payload: { eventId: '33333333-3333-4333-8333-333333333333', type: 'completed', trackId: TRACK_ID, positionMs: 5_000 },
});
expect(plan.statusCode).toBe(200);
expect(coordinator.getPlan).toHaveBeenCalledWith(USER_ID, SESSION_ID, 2);
expect(event.statusCode).toBe(200);
expect(coordinator.appendEvent).toHaveBeenCalledWith(USER_ID, SESSION_ID, expect.objectContaining({ type: 'completed', positionMs: 5_000 }));
await app.close();
});
it('validates occurredAt and exposes owned resume and advance operations', async () => {
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
const resume = await app.inject({
method: 'POST', url: '/v2/vibe/sessions', payload: { resumeSessionId: SESSION_ID },
});
const badTime = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`,
payload: { type: 'completed', occurredAt: 'not-a-date' },
});
const next = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance` });
expect(resume.statusCode).toBe(201);
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ resumeSessionId: SESSION_ID }));
expect(badTime.statusCode).toBe(400);
expect(next.statusCode).toBe(200);
expect(coordinator.serveNext).toHaveBeenCalledWith(USER_ID, SESSION_ID);
await app.close();
});
it('passes a version-aware advance request through and rejects an invalid expected version', async () => {
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
const valid = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 2 },
});
const invalid = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 0 },
});
expect(valid.statusCode).toBe(200);
expect(coordinator.serveNext).toHaveBeenCalledWith(USER_ID, SESSION_ID, 2);
expect(invalid.statusCode).toBe(400);
expect(coordinator.serveNext).toHaveBeenCalledTimes(1);
await app.close();
});
it('uses the explicit versioned advancement protocol for a served unplayable item', async () => {
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
const result = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`,
payload: {
expectedPlanVersion: 2,
unplayable: {
eventId: '33333333-3333-4333-8333-333333333333',
planVersionId: '44444444-4444-4444-8444-444444444444',
ordinal: 0,
trackId: TRACK_ID,
},
},
});
expect(result.statusCode).toBe(200);
expect(coordinator.advancePastUnplayable).toHaveBeenCalledWith(USER_ID, SESSION_ID, {
expectedPlanVersion: 2,
eventId: '33333333-3333-4333-8333-333333333333',
planVersionId: '44444444-4444-4444-8444-444444444444',
ordinal: 0,
trackId: TRACK_ID,
});
expect(coordinator.serveNext).not.toHaveBeenCalled();
await app.close();
});
});
+197
View File
@@ -0,0 +1,197 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import {
VIBE_EVENT_TYPES,
VibeSessionCoordinator,
VibeSessionLifecycleError,
VibeSessionNotFoundError,
VibePlanNotFoundError,
} from '../services/vibe-session-coordinator.service.js';
import { VibeCalendarContext } from '../services/generators.service.js';
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/i;
const CLIENT_VIBE_EVENT_TYPES = VIBE_EVENT_TYPES.filter((type) => ![
'session_started', 'session_resumed', 'session_ended', 'plan_published', 'track_served',
].includes(type));
type Reply = { code: (statusCode: number) => { send: (payload: unknown) => unknown } };
type Body = Record<string, unknown>;
/** This mirrors the rest of the application until authentication owns identity. */
export type VibeIdentityResolver = (request: FastifyRequest) => string | null;
function isObject(value: unknown): value is Body {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function validUuid(value: unknown): value is string {
return value === DEFAULT_USER_ID || (typeof value === 'string' && UUID_RE.test(value));
}
function requestUser(request: FastifyRequest, resolveIdentity?: VibeIdentityResolver): string | null {
const resolved = resolveIdentity?.(request);
if (resolved !== undefined) return validUuid(resolved) ? resolved : null;
const header = request.headers['x-user-id'];
const userId = typeof header === 'string' && header ? header : DEFAULT_USER_ID;
return validUuid(userId) ? userId : null;
}
function validOccurredAt(value: unknown): value is string {
return typeof value === 'string' && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value));
}
function parseCalendarContext(value: unknown): VibeCalendarContext | undefined | null {
if (value === undefined) return undefined;
if (!isObject(value)) return null;
const { localHour, weekday, month, timeZone } = value;
if (typeof localHour !== 'number' || !Number.isInteger(localHour) || localHour < 0 || localHour > 23) return null;
if (typeof weekday !== 'number' || !Number.isInteger(weekday) || weekday < 0 || weekday > 6) return null;
if (typeof month !== 'number' || !Number.isInteger(month) || month < 1 || month > 12) return null;
if (timeZone !== undefined && (typeof timeZone !== 'string' || timeZone.length > 64)) return null;
return { localHour, weekday, month, ...(typeof timeZone === 'string' ? { timeZone } : {}) };
}
function validationError(reply: Reply, message: string) {
return reply.code(400).send({ error: message });
}
function sessionIdFrom(request: FastifyRequest, reply: Reply): string | null {
const { sessionId } = request.params as { sessionId: string };
return validUuid(sessionId) ? sessionId : (validationError(reply, 'sessionId must be a UUID'), null);
}
function parseStart(body: unknown):
| { seedTrackId?: string; resumeSessionId?: string; context?: VibeCalendarContext }
| { error: string } {
const input = isObject(body) ? body : {};
if (input.resumeSessionId !== undefined && !validUuid(input.resumeSessionId)) return { error: 'resumeSessionId must be a UUID' };
if (input.seedTrackId !== undefined && !validUuid(input.seedTrackId)) return { error: 'seedTrackId must be a UUID' };
if (input.resumeSessionId !== undefined && input.seedTrackId !== undefined) return { error: 'resumeSessionId cannot be combined with seedTrackId' };
const context = parseCalendarContext(input.context);
if (context === null) return { error: 'context must contain valid localHour, weekday, month, and optional timeZone' };
return {
seedTrackId: input.seedTrackId as string | undefined,
resumeSessionId: input.resumeSessionId as string | undefined,
...(context ? { context } : {}),
};
}
function parseEvent(body: unknown):
| { type: typeof CLIENT_VIBE_EVENT_TYPES[number]; eventId?: string; trackId?: string; occurredAt?: Date; positionMs?: number; durationMs?: number; payload?: Body }
| { error: string } {
if (!isObject(body)) return { error: 'event body must be an object' };
if (!CLIENT_VIBE_EVENT_TYPES.includes(body.type as typeof CLIENT_VIBE_EVENT_TYPES[number])) return { error: 'type must be a supported client Vibe event type' };
if (body.eventId !== undefined && !validUuid(body.eventId)) return { error: 'eventId must be a UUID' };
if (body.trackId !== undefined && !validUuid(body.trackId)) return { error: 'trackId must be a UUID' };
if (body.occurredAt !== undefined && !validOccurredAt(body.occurredAt)) return { error: 'occurredAt must be an ISO-8601 timestamp' };
if (body.positionMs !== undefined && (typeof body.positionMs !== 'number' || !Number.isInteger(body.positionMs) || body.positionMs < 0)) return { error: 'positionMs must be a non-negative integer' };
if (body.durationMs !== undefined && (typeof body.durationMs !== 'number' || !Number.isInteger(body.durationMs) || body.durationMs < 0)) return { error: 'durationMs must be a non-negative integer' };
if (body.payload !== undefined && !isObject(body.payload)) return { error: 'payload must be an object' };
return {
type: body.type as typeof CLIENT_VIBE_EVENT_TYPES[number],
eventId: body.eventId as string | undefined,
trackId: body.trackId as string | undefined,
occurredAt: body.occurredAt === undefined ? undefined : new Date(body.occurredAt as string),
positionMs: body.positionMs as number | undefined,
durationMs: body.durationMs as number | undefined,
payload: body.payload as Body | undefined,
};
}
function parseAdvance(body: unknown):
| { expectedPlanVersion?: number; unplayable?: { eventId: string; planVersionId: string; ordinal: number; trackId: string } }
| { error: string } {
const input = isObject(body) ? body : {};
const version = input.expectedPlanVersion;
if (version !== undefined && (typeof version !== 'number' || !Number.isInteger(version) || version < 1)) return { error: 'expectedPlanVersion must be a positive integer' };
if (input.unplayable === undefined) return { expectedPlanVersion: version as number | undefined };
if (!isObject(input.unplayable)) return { error: 'unplayable must be an object' };
const unplayable = input.unplayable;
if (version === undefined) return { error: 'expectedPlanVersion is required when advancing an unplayable item' };
if (!validUuid(unplayable.eventId) || !validUuid(unplayable.planVersionId) || !validUuid(unplayable.trackId) || typeof unplayable.ordinal !== 'number' || !Number.isInteger(unplayable.ordinal) || unplayable.ordinal < 0) {
return { error: 'unplayable requires UUID eventId, planVersionId, trackId and a non-negative integer ordinal' };
}
return { expectedPlanVersion: version as number, unplayable: unplayable as { eventId: string; planVersionId: string; ordinal: number; trackId: string } };
}
export default async function vibeSessionsRoutes(
fastify: FastifyInstance,
options: { coordinator: VibeSessionCoordinator; identityResolver?: VibeIdentityResolver },
) {
const { coordinator, identityResolver } = options;
const userFor = (request: FastifyRequest, reply: Reply) => {
const userId = requestUser(request, identityResolver);
return userId ? userId : (validationError(reply, 'x-user-id must be a UUID'), null);
};
fastify.post('/v2/vibe/sessions', async (request, reply) => {
const userId = userFor(request, reply);
const input = parseStart(request.body);
if (!userId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
try {
return reply.code(201).send(await coordinator.start(userId, input));
} catch (error) {
return sendCoordinatorError(reply, error);
}
});
fastify.get('/v2/vibe/sessions/:sessionId/plans', async (request, reply) => {
const userId = userFor(request, reply);
const sessionId = sessionIdFrom(request, reply);
const { version } = request.query as { version?: string };
const parsedVersion = version === undefined ? undefined : Number(version);
if (!userId || !sessionId) return;
if (version !== undefined && (!Number.isInteger(parsedVersion) || parsedVersion! < 1)) return validationError(reply, 'version must be a positive integer');
try {
return reply.send(await coordinator.getPlan(userId, sessionId, parsedVersion));
} catch (error) {
return sendCoordinatorError(reply, error);
}
});
fastify.post('/v2/vibe/sessions/:sessionId/events', async (request, reply) => {
const userId = userFor(request, reply);
const sessionId = sessionIdFrom(request, reply);
const input = parseEvent(request.body);
if (!userId || !sessionId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
try {
return reply.send(await coordinator.appendEvent(userId, sessionId, input));
} catch (error) {
return sendCoordinatorError(reply, error);
}
});
fastify.post('/v2/vibe/sessions/:sessionId/end', async (request, reply) => {
const userId = userFor(request, reply);
const sessionId = sessionIdFrom(request, reply);
if (!userId || !sessionId) return;
try {
return reply.send(await coordinator.end(userId, sessionId));
} catch (error) {
return sendCoordinatorError(reply, error);
}
});
fastify.post('/v2/vibe/sessions/:sessionId/advance', async (request, reply) => {
const userId = userFor(request, reply);
const sessionId = sessionIdFrom(request, reply);
const input = parseAdvance(request.body);
if (!userId || !sessionId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
try {
return reply.send(input.unplayable
? await coordinator.advancePastUnplayable(userId, sessionId, { expectedPlanVersion: input.expectedPlanVersion!, ...input.unplayable })
: input.expectedPlanVersion === undefined
? await coordinator.serveNext(userId, sessionId)
: await coordinator.serveNext(userId, sessionId, input.expectedPlanVersion));
} catch (error) {
return sendCoordinatorError(reply, error);
}
});
}
function sendCoordinatorError(reply: Reply, error: unknown) {
if (error instanceof VibeSessionNotFoundError || error instanceof VibePlanNotFoundError) return reply.code(404).send({ error: error.message });
if (error instanceof VibeSessionLifecycleError) return reply.code(409).send({ error: error.message, code: 'VIBE_SESSION_NOT_ACTIVE' });
throw error;
}
+693
View File
@@ -7,7 +7,631 @@ function makeService(): { service: DbService; mockQuery: ReturnType<typeof vi.fn
return { service, mockQuery };
}
function makeTransactionalService(): { service: DbService; poolQuery: ReturnType<typeof vi.fn>; clientQuery: ReturnType<typeof vi.fn> } {
const poolQuery = vi.fn();
const clientQuery = vi.fn();
const service = new DbService({
query: poolQuery,
connect: vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() }),
} as any);
return { service, poolQuery, clientQuery };
}
describe('DbService v2 methods', () => {
describe('durable Vibe sessions', () => {
it('projects unfamiliar feedback into exploration exactly once behind its own marker', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', session_id: 'session-1', user_id: 'user-1', track_id: 'track-1',
type: 'completed', occurred_at: new Date(), client_event_id: null,
position_ms: null, duration_ms: null, payload: {},
} as any;
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill
.mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker
.mockResolvedValueOnce({ rows: [{ context: {} }] }) // no calendar context on legacy session
.mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity
.mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence
.mockResolvedValueOnce({ rowCount: 1 }) // discovery belief
.mockResolvedValueOnce({ rows: [] }) // no artist/genre targets
.mockResolvedValueOnce({ rows: [] }) // no audio targets
.mockResolvedValueOnce({ rows: [{
exploration_coefficient: 0.36, discovery_radius: 0.434,
goals: { type: 'familiar', target: 1, progress: 1 },
}] })
.mockResolvedValueOnce({ rows: [] }) // session_state projection
.mockResolvedValueOnce({ rows: [] }) // COMMIT
.mockResolvedValueOnce({ rows: [] }) // BEGIN retry
.mockResolvedValueOnce({ rows: [] }) // profile upsert retry
.mockResolvedValueOnce({ rows: [] }) // marker conflict
.mockResolvedValueOnce({ rows: [] }); // COMMIT retry
await service.projectVibeSessionFeedback(event);
await service.projectVibeSessionFeedback(event);
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_session_profiles');
expect(clientQuery.mock.calls[2][0]).toContain('vibe_session_feedback_projections');
expect(clientQuery.mock.calls[3][0]).toContain('SELECT context FROM vibe_sessions');
expect(clientQuery.mock.calls[4][0]).toContain('EXISTS (SELECT 1 FROM play_history');
expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO evidence');
expect(clientQuery.mock.calls[9][0]).toContain('exploration_coefficient');
expect(clientQuery.mock.calls[9][0]).toContain('ELSE goals END');
expect(clientQuery.mock.calls[9][0]).toContain('ELSE $3::real END');
expect(clientQuery.mock.calls[10][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 }));
expect(clientQuery.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO evidence'))).toHaveLength(1);
});
it('creates, reads, and ends sessions scoped to their user', async () => {
const { service, poolQuery, clientQuery } = makeTransactionalService();
const session = {
id: 'session-1', user_id: 'user-1', status: 'active', seed_track_id: null,
context: { activity: 'focus' }, policy_version: 'v2.1',
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
.mockResolvedValueOnce({ rows: [] }) // active-session lock
.mockResolvedValueOnce({ rows: [session] }) // insert
.mockResolvedValueOnce({ rows: [] }); // COMMIT
poolQuery
.mockResolvedValueOnce({ rows: [session] })
.mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] });
await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' })).resolves.toEqual(session);
await expect(service.getVibeSession('session-1', 'user-1')).resolves.toEqual(session);
await expect(service.endVibeSession('session-1', 'user-1')).resolves.toMatchObject({ status: 'ended' });
expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock');
expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE");
expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions');
expect(clientQuery.mock.calls[3][1]).toEqual(expect.arrayContaining([
'user-1', null, expect.any(String), 'v2.1',
JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
]));
expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual({});
expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2');
expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())');
expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
});
it('starts sessions without inventing client context', async () => {
const { service, clientQuery } = makeTransactionalService();
const session = { id: 'session-1', user_id: 'user-1', status: 'active' };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
.mockResolvedValueOnce({ rows: [] }) // active-session lock
.mockResolvedValueOnce({ rows: [session] }) // insert
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' });
const insertParameters = clientQuery.mock.calls[3][1];
expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]);
expect(JSON.parse(insertParameters[2])).toEqual({});
expect(insertParameters.slice(3)).toEqual([
'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
]);
});
it('replaces an owned active session and writes its terminal event before starting another', async () => {
const { service, clientQuery } = makeTransactionalService();
const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // active lock
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // replace
.mockResolvedValueOnce({ rows: [] }) // terminal event
.mockResolvedValueOnce({ rows: [replacement] }) // new session
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' }))
.resolves.toEqual(replacement);
expect(clientQuery.mock.calls[3][0]).toContain("status = 'replaced'");
expect(clientQuery.mock.calls[4][0]).toContain("'session_ended'");
expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO vibe_sessions');
});
it('records retry-safe events and reports whether the event was inserted', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
user_id: 'user-1', track_id: 'track-1', type: 'skipped', occurred_at: new Date(),
position_ms: 1_500, duration_ms: 10_000, payload: { reason: 'next' },
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock session
.mockResolvedValueOnce({ rows: [event] }) // existing retry
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const result = await service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
trackId: 'track-1', type: 'skipped', positionMs: 1_500, durationMs: 10_000,
payload: { reason: 'next' },
});
expect(result.inserted).toBe(false);
expect(result.event.id).toBe('event-1');
const [sql, values] = clientQuery.mock.calls[1];
expect(sql).toContain('FOR UPDATE');
expect(values).toEqual([
'session-1', 'user-1',
]);
expect(clientQuery.mock.calls).toHaveLength(5);
expect(clientQuery.mock.calls[3][0]).toContain('vibe_event_projections');
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
expect.stringContaining('UPDATE vibe_sessions')
);
});
it('records a non-material event without mutating session context', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null,
type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null,
payload: { source: 'player' },
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
.mockResolvedValueOnce({ rows: [event] }) // insert
.mockResolvedValueOnce({ rows: [] }) // last event timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', type: 'progress', payload: { source: 'player' },
});
const values = clientQuery.mock.calls[2][1];
const storedPayload = JSON.parse(values[8]);
expect(storedPayload).toEqual({ source: 'player' });
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
expect.stringContaining('SET context = $3::jsonb'),
);
});
it('does not apply a retry body to an existing event', async () => {
const { service, clientQuery } = makeTransactionalService();
const canonicalEvent = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null,
type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null,
payload: { source: 'player' },
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
.mockResolvedValueOnce({ rows: [canonicalEvent] }) // canonical retry event
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const result = await service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'progress',
payload: { source: 'retry' },
});
expect(result).toEqual({ event: canonicalEvent, inserted: false });
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
expect.stringContaining('SET context = $3::jsonb'),
);
});
it('projects material feedback once with the durable event transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
user_id: 'user-1', track_id: 'track-1', type: 'completed', occurred_at: new Date(),
position_ms: null, duration_ms: null, payload: {},
};
const evidence = vi.spyOn(service as any, 'recordTrackEvidence').mockResolvedValue('evidence-1');
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
.mockResolvedValueOnce({ rows: [] }) // no existing idempotency key
.mockResolvedValueOnce({ rows: [event] }) // event insert
.mockResolvedValueOnce({ rows: [{ event_id: 'event-1' }] }) // projection marker
.mockResolvedValueOnce({ rows: [] }) // play history
.mockResolvedValueOnce({ rows: [] }) // track counter
.mockResolvedValueOnce({ rows: [] }) // session timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
type: 'completed', trackId: 'track-1',
})).resolves.toMatchObject({ inserted: true, event: { id: 'event-1' } });
expect(clientQuery.mock.calls.map(([sql]) => sql)).toEqual(expect.arrayContaining([
expect.stringContaining('vibe_event_projections'),
expect.stringContaining('INSERT INTO play_history'),
]));
expect(evidence).toHaveBeenCalledTimes(1);
});
it('rejects an event when no owned session is returned', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // session lookup
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'other-user', type: 'completed',
})).rejects.toThrow('not found or is not owned');
});
it('rejects new events for terminal sessions but returns an existing idempotent retry', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
user_id: 'user-1', track_id: null, type: 'completed', occurred_at: new Date(),
position_ms: null, duration_ms: null, payload: {},
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN: idempotent retry
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
.mockResolvedValueOnce({ rows: [event] })
.mockResolvedValueOnce({ rows: [] }) // COMMIT
.mockResolvedValueOnce({ rows: [] }) // BEGIN: new event
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
.mockResolvedValueOnce({ rows: [] }) // no matching retry
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'completed',
})).resolves.toEqual({ event, inserted: false });
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'new-event-1', type: 'completed',
})).rejects.toThrow('Cannot record a new event for ended Vibe session');
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
expect.stringContaining('INSERT INTO vibe_events')
);
});
it('locks the terminal transition with its event and makes terminal retries no-ops', async () => {
const { service, clientQuery } = makeTransactionalService();
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
const ended = { ...active, status: 'ended', ended_at: new Date() };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [active] }) // lock
.mockResolvedValueOnce({ rows: [] }) // terminal event
.mockResolvedValueOnce({ rows: [ended] }) // status transition
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.endVibeSessionWithEvent('session-1', 'user-1'))
.resolves.toEqual({ session: ended, ended: true });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[2][0]).toContain("'session_ended'");
expect(clientQuery.mock.calls[3][0]).toContain("status = 'ended'");
});
it('resumes an owned session once and records session_resumed in the same lock', async () => {
const { service, clientQuery } = makeTransactionalService();
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ ...active, status: 'paused' }] }) // lock
.mockResolvedValueOnce({ rows: [] }) // no old resume event
.mockResolvedValueOnce({ rows: [active] }) // activate
.mockResolvedValueOnce({ rows: [] }) // ledger event
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.resumeVibeSession('session-1', 'user-1'))
.resolves.toEqual({ session: active, resumed: true });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[4][0]).toContain("'session_resumed'");
});
});
describe('durable Vibe plans', () => {
it('publishes a revision and its plan_published event in one transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
const published = {
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
.mockResolvedValueOnce({ rows: [published] }) // header
.mockResolvedValueOnce({ rows: [] }) // item
.mockResolvedValueOnce({ rows: [] }) // plan_published
.mockResolvedValueOnce({ rows: [] }) // timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.publishVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: {}, objectiveSnapshot: {},
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
})).resolves.toMatchObject({ version: 1, items: [{ track_id: 'track-1' }] });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[4][0]).toContain("'plan_published'");
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('COMMIT');
});
it('rolls back the plan header and items if writing plan_published fails', async () => {
const { service, clientQuery } = makeTransactionalService();
const published = {
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
.mockResolvedValueOnce({ rows: [published] }) // header
.mockResolvedValueOnce({ rows: [] }) // item
.mockRejectedValueOnce(new Error('ledger write failed'))
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.publishVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: {}, objectiveSnapshot: {},
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
})).rejects.toThrow('ledger write failed');
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('ROLLBACK');
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain('COMMIT');
});
it('refuses a delayed initial publication after a concurrent start replaced its session', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'replaced' }] })
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.publishVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: {}, objectiveSnapshot: {}, items: [],
})).rejects.toThrow('Cannot publish a plan for replaced Vibe session');
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('INSERT INTO vibe_plan_versions'));
});
it('reads every durable session track as a replacement-plan exclusion', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValueOnce({ rows: [{ track_id: 'served' }, { track_id: 'skipped' }, { track_id: 'disliked' }] });
await expect(service.getVibeSessionTrackIds('session-1', 'user-1'))
.resolves.toEqual(['served', 'skipped', 'disliked']);
expect(mockQuery.mock.calls[0][0]).toContain('SELECT DISTINCT e.track_id');
expect(mockQuery.mock.calls[0][0]).toContain('e.track_id IS NOT NULL');
});
it('writes a header and all items in one transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
state_snapshot: { energy: 0.5 }, objective_snapshot: { freshness: 0.4 }, created_at: new Date(),
}] })
.mockResolvedValueOnce({ rows: [] }) // item
.mockResolvedValueOnce({ rows: [] }); // COMMIT
const plan = await service.persistVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: { energy: 0.5 }, objectiveSnapshot: { freshness: 0.4 },
items: [{
ordinal: 0, track_id: 'track-1', slot_role: 'anchor', candidate_source: 'comfort',
score: 0.91, score_breakdown: { affinity: 0.8 }, explanation: [{ because: 'favourite' }], committed: true,
}],
});
expect(plan.items[0].plan_version_id).toBe('plan-1');
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_plan_versions');
expect(clientQuery.mock.calls[2][0]).toContain('INSERT INTO vibe_plan_items');
expect(clientQuery.mock.calls[3][0]).toBe('COMMIT');
});
it('serves and commits one next item under the session lock', async () => {
const { service, clientQuery } = makeTransactionalService();
const item = {
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
.mockResolvedValueOnce({ rows: [item] }) // commit item
.mockResolvedValueOnce({ rows: [] }) // track_served event
.mockResolvedValueOnce({ rows: [] }) // timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.serveNextVibePlanItem('session-1', 'user-1')).resolves.toEqual({ item, stale: false });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[3][0]).toContain('SET committed = true');
expect(clientQuery.mock.calls[3][1]).toEqual(['plan-1']);
expect(clientQuery.mock.calls[4][0]).toContain("'track_served'");
});
it('returns a newer preview signal without committing when the expected plan is stale', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-2', version: 2 }] }) // latest
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
.resolves.toEqual({ item: null, stale: true });
expect(clientQuery.mock.calls).toHaveLength(4);
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
});
it('returns the original item when a version-aware next request is retried', async () => {
const { service, clientQuery } = makeTransactionalService();
const item = {
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest
.mockResolvedValueOnce({ rows: [item] }) // prior served item
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
.resolves.toEqual({ item, stale: false });
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
});
it('advances a served unplayable item with a separate idempotent event and commits one replacement', async () => {
const { service, clientQuery } = makeTransactionalService();
const replacement = {
plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null,
candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true,
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
.mockResolvedValueOnce({ rows: [] }) // no prior playback_error event
.mockResolvedValueOnce({ rows: [{ track_id: 'track-1', ordinal: 0 }] }) // current served cursor
.mockResolvedValueOnce({ rows: [{ id: 'error-1' }] }) // playback_error event
.mockResolvedValueOnce({ rows: [replacement] }) // commit replacement
.mockResolvedValueOnce({ rows: [] }) // replacement track_served event
.mockResolvedValueOnce({ rows: [] }) // playback_error result payload
.mockResolvedValueOnce({ rows: [] }) // session timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
expectedPlanVersion: 1,
planVersionId: 'plan-1',
ordinal: 0,
trackId: 'track-1',
eventId: 'event-1',
})).resolves.toEqual({ item: replacement, stale: false });
expect(clientQuery.mock.calls[5][0]).toContain("'playback_error'");
expect(clientQuery.mock.calls[6][0]).toContain('SET committed = true');
expect(clientQuery.mock.calls[7][0]).toContain("'track_served'");
expect(clientQuery.mock.calls[8][0]).toContain('UPDATE vibe_events SET payload');
});
it('refuses an old served cursor when events share a timestamp by ordering the immutable plan ordinal', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
.mockResolvedValueOnce({ rows: [] }) // no prior playback_error event
// The lower-ordinal event can have a lexically greater UUID at the
// same occurred_at. The cursor must still be the highest immutable
// plan ordinal, never whichever UUID sorts last.
.mockResolvedValueOnce({ rows: [{ track_id: 'track-2', ordinal: 1 }] }) // current served cursor
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
expectedPlanVersion: 1,
planVersionId: 'plan-1',
ordinal: 0,
trackId: 'track-1',
eventId: 'event-1',
})).rejects.toThrow('not the current served cursor');
expect(clientQuery.mock.calls[4][0]).toContain('JOIN vibe_plan_items');
expect(clientQuery.mock.calls[4][0]).toContain('ORDER BY i.ordinal DESC');
expect(clientQuery.mock.calls[4][0]).not.toContain('id DESC');
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
});
it('retries an unplayable advancement with the same event id without consuming another item', async () => {
const { service, clientQuery } = makeTransactionalService();
const replacement = {
plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null,
candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true,
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
.mockResolvedValueOnce({ rows: [{ type: 'playback_error', payload: {
planVersionId: 'plan-1', ordinal: 0, trackId: 'track-1',
advancedTo: { planVersionId: 'plan-1', ordinal: 1 },
} }] }) // prior explicit advancement
.mockResolvedValueOnce({ rows: [replacement] }) // canonical replacement
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
expectedPlanVersion: 1,
planVersionId: 'plan-1',
ordinal: 0,
trackId: 'track-1',
eventId: 'event-1',
})).resolves.toEqual({ item: replacement, stale: false });
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
expect(clientQuery.mock.calls).toHaveLength(6);
});
it('reads the latest revision and reconstructs ordered plan items', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{
id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
item_plan_version_id: 'plan-2', ordinal: 0, track_id: 'track-2', slot_role: 'next',
candidate_source: 'adjacent', score: 0.8, score_breakdown: { transition: 0.7 },
explanation: [{ because: 'similar artist' }], committed: true,
}, {
id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
item_plan_version_id: 'plan-2', ordinal: 1, track_id: 'track-3', slot_role: null,
candidate_source: 'discovery', score: 0.6, score_breakdown: { novelty: 0.5 },
explanation: [], committed: false,
}] });
const plan = await service.getVibePlan('session-1', 'user-1');
expect(plan?.version).toBe(2);
expect(plan?.items.map((item) => item.track_id)).toEqual(['track-2', 'track-3']);
expect(mockQuery.mock.calls[0][0]).toContain('SELECT MAX(version) FROM vibe_plan_versions');
expect(mockQuery.mock.calls[0][1]).toEqual(['session-1', 'user-1', null]);
});
});
describe('createAlbum', () => {
it('persists the canonical release date instead of discarding it', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'album-1' }] });
await service.createAlbum({
id: 'album-1', artist_id: 'artist-1', title: 'Album',
year: 2026, release_date: '2026-07-15', artwork_id: null,
});
const [sql, params] = mockQuery.mock.calls[0];
expect(sql).toContain('release_date');
expect(params[3]).toBe('2026-07-15');
});
});
describe('recordPlay durability', () => {
it('copies the track identity onto the row and records what was heard', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'history-1' }] });
await service.recordPlay('user-1', 'track-1', false, undefined, 91_000);
const [sql, params] = mockQuery.mock.calls[0];
// A LEFT JOIN, not a plain VALUES: the insert must still happen when the
// track id resolves to nothing.
expect(sql).toContain('LEFT JOIN tracks');
expect(sql).toContain('t.title');
expect(sql).toContain('t.artist');
expect(params).toEqual(['user-1', 'track-1', null, false, 91_000]);
});
it('leaves listened_ms null when the caller has no duration to report', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{ id: 'history-1' }] });
await service.recordPlay('user-1', 'track-1', false);
expect(mockQuery.mock.calls[0][1][4]).toBeNull();
});
});
describe('upsertClaim', () => {
it('calls INSERT ... ON CONFLICT with correct parameters', async () => {
const { service, mockQuery } = makeService();
@@ -168,6 +792,75 @@ describe('DbService v2 methods', () => {
});
});
describe('track evidence propagation', () => {
it('projects a favourite onto artist, genre, and present audio dimensions', async () => {
const { service, mockQuery } = makeService();
let evidenceNumber = 0;
mockQuery.mockImplementation((sql: string) => {
if (sql.includes('INSERT INTO evidence')) return Promise.resolve({ rows: [{ id: `ev-${++evidenceNumber}` }] });
if (sql.includes('WITH artist_ids')) {
return Promise.resolve({ rows: [
{ entity_type: 'artist', entity_id: 'artist-1' },
{ entity_type: 'genre', entity_id: 'genre-1' },
] });
}
if (sql.includes('SELECT energy, bpm, valence')) {
return Promise.resolve({ rows: [{ energy: 0.81, bpm: 128, valence: 0.22 }] });
}
return Promise.resolve({ rowCount: 1, rows: [] });
});
await service.recordTrackEvidence({
user_id: 'user-1', track_id: 'track-1', signal: 'add_to_favorites',
profile: 'longterm', weight: 0.60,
});
const evidenceWrites = mockQuery.mock.calls
.filter(([sql]) => (sql as string).includes('INSERT INTO evidence'))
.map(([, params]) => params as unknown[]);
expect(evidenceWrites.map(params => params[1])).toEqual([
'track', 'artist', 'genre', 'audio', 'audio', 'audio',
]);
const beliefWrites = mockQuery.mock.calls
.filter(([sql]) => (sql as string).includes('INSERT INTO listener_beliefs'))
.map(([, params]) => params as unknown[]);
const artistBelief = beliefWrites.find(params => params[2] === 'artist');
const genreBelief = beliefWrites.find(params => params[2] === 'genre');
expect(artistBelief?.[3]).toBe('artist-1');
expect(artistBelief?.[5]).toBeCloseTo(0.54); // one favourite is a usable comfort signal
expect(genreBelief?.[5]).toBeCloseTo(0.27);
expect(beliefWrites.filter(params => params[2] === 'audio')).toHaveLength(3);
});
it('rebuilds shared beliefs from local completed plays and feedback without appending evidence', async () => {
const clientQuery = vi.fn((sql: string) => {
if (sql === 'BEGIN' || sql === 'COMMIT' || sql === 'ROLLBACK') return Promise.resolve({ rows: [] });
if (sql.includes('DELETE FROM listener_beliefs')) return Promise.resolve({ rowCount: 0, rows: [] });
if (sql.includes('FROM play_history ph')) {
return Promise.resolve({ rows: [{
track_id: 'track-1', signal: 'playback_completed', profile: 'longterm', weight: 0.10,
}] });
}
if (sql.includes('WITH artist_ids')) return Promise.resolve({ rows: [{ entity_type: 'artist', entity_id: 'artist-1' }] });
if (sql.includes('SELECT energy, bpm, valence')) return Promise.resolve({ rows: [] });
return Promise.resolve({ rowCount: 1, rows: [] });
});
const connect = vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() });
const service = new DbService({ query: vi.fn(), connect } as any);
const result = await service.rebuildDerivedListenerBeliefs('user-1');
expect(result).toEqual({ interactions: 1, beliefs: 1 });
expect(clientQuery.mock.calls.some(([sql]) => (sql as string).includes('FROM feedback f'))).toBe(true);
expect(clientQuery.mock.calls.some(([sql]) => (sql as string).includes('INSERT INTO evidence'))).toBe(false);
const beliefCall = (clientQuery.mock.calls as unknown as Array<[string, unknown[]]>).
find(([sql]) => sql.includes('INSERT INTO listener_beliefs'));
const beliefParams = beliefCall?.[1] ?? [];
expect(beliefParams.slice(1, 5)).toEqual(['longterm', 'artist', 'artist-1', 'affinity']);
});
});
describe('getFusedTrackArtists', () => {
it('reads from claim_fusion view', async () => {
const { service, mockQuery } = makeService();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { DiscoveryService } from './discovery.service.js';
import type { DbService } from './db.service.js';
function makeDb() {
const query = vi.fn();
return {
pgClient: { query },
getListenerBeliefs: vi.fn(),
upsertClaim: vi.fn().mockResolvedValue('claim-id'),
} as unknown as DbService;
}
describe('DiscoveryService System E provenance', () => {
it('writes graph-walk claims against discovery_candidate using registered graph provenance', async () => {
const db = makeDb();
(db.getListenerBeliefs as any).mockResolvedValue([
{ entity_id: '11111111-1111-1111-1111-111111111111', value: 0.8 },
]);
(db.pgClient.query as any)
.mockResolvedValueOnce({ rows: [{ candidate_artist_id: '22222222-2222-2222-2222-222222222222' }] })
.mockResolvedValueOnce({ rows: [{ id: '33333333-3333-3333-3333-333333333333' }] });
await new DiscoveryService(db).walkGraphForDiscovery('00000000-0000-0000-0000-000000000000');
expect(db.upsertClaim).toHaveBeenCalledWith(expect.objectContaining({
subject_type: 'discovery_candidate',
source: 'graph_exploration',
}));
});
it('does not queue artist-only graph candidates without a vetted acquisition URL', async () => {
const db = makeDb();
(db.pgClient.query as any)
.mockResolvedValueOnce({ rows: [{ id: 'candidate-1', notes: { discovery_source: 'graph_exploration' } }] })
.mockResolvedValueOnce({ rows: [{ cnt: 0 }] })
.mockResolvedValueOnce({ rows: [{ object_id: 'artist-1', fused_value: 0.7 }] })
.mockResolvedValueOnce({ rows: [] });
const result = await new DiscoveryService(db).evalCandidates('00000000-0000-0000-0000-000000000000');
expect(result).toEqual([{
candidateId: 'candidate-1', shouldAcquire: false, reason: 'awaiting resolved acquisition source',
}]);
expect((db.pgClient.query as any).mock.calls.at(-1)[0]).toContain("status = 'awaiting_resolution'");
});
});
+58 -6
View File
@@ -83,12 +83,17 @@ export class DiscoveryService {
const dcId = dcRes.rows[0].id;
const relevance = Math.min(belief.value, 0.8);
// A candidate is not a track. Claims intentionally support generic
// entity types, so keeping this distinction prevents graph reads from
// treating a random candidate UUID as a library track UUID.
await this.db.upsertClaim({
subject_type: 'track',
subject_type: 'discovery_candidate',
subject_id: dcId,
predicate: 'discovery_candidate',
object_type: 'artist',
object_id: row.candidate_artist_id,
// Registered in source_trust by the System E migration. Previously
// this unregistered value violated claims.source's foreign key.
source: 'graph_exploration',
confidence: relevance,
raw: {
@@ -134,7 +139,7 @@ export class DiscoveryService {
const claimRes = await this.db.pgClient.query<{ object_id: string; fused_value: number }>(
`SELECT object_id, fused_value
FROM claim_fusion
WHERE subject_type = 'track' AND subject_id = $1::uuid
WHERE subject_type = 'discovery_candidate' AND subject_id = $1::uuid
AND predicate = 'discovery_candidate'
LIMIT 1`,
[row.id]
@@ -143,6 +148,27 @@ export class DiscoveryService {
const relevance = claimRes.rows[0]?.fused_value ?? 0;
const candidateArtistId = claimRes.rows[0]?.object_id;
// A candidate must name what to fetch: either a vetted HTTPS URL, or a
// track-level search phrase the worker resolves against its allow-listed
// hosts. An artist-only candidate (what a bare graph walk produces) names
// neither, and parks here rather than downloading something arbitrary
// under that artist's name.
const notes = typeof row.notes === 'string' ? safeJson(row.notes) : row.notes;
const acquisition = (notes as { acquisition?: { url?: unknown; query?: unknown } } | null)?.acquisition;
const hasUrl = typeof acquisition?.url === 'string' && acquisition.url.trim() !== '';
const hasQuery = typeof acquisition?.query === 'string' && acquisition.query.trim() !== '';
if (!hasUrl && !hasQuery) {
await this.db.pgClient.query(
`UPDATE discovery_candidates
SET status = 'awaiting_resolution', last_eval_at = NOW(),
last_error = 'no acquisition url or search query attached'
WHERE id = $1`,
[row.id]
);
results.push({ candidateId: row.id, shouldAcquire: false, reason: 'awaiting resolved acquisition source' });
continue;
}
const noveltyBeliefs = await this.db.getListenerBeliefs({
userId,
profile: 'discovery',
@@ -160,6 +186,7 @@ export class DiscoveryService {
FROM discovery_candidates dc
JOIN claims c ON c.subject_id = dc.id
WHERE dc.status = 'acquiring'
AND c.subject_type = 'discovery_candidate'
AND c.predicate = 'discovery_candidate'
AND c.object_id = $1::uuid`,
[candidateArtistId]
@@ -200,6 +227,16 @@ export class DiscoveryService {
return results;
}
/** Undo the state transition when Redis rejected an acquisition enqueue. */
async markEnqueueFailed(candidateId: string, reason: string): Promise<void> {
await this.db.pgClient.query(
`UPDATE discovery_candidates
SET status = 'candidate', last_eval_at = NOW(), last_error = $2
WHERE id = $1 AND status = 'acquiring'`,
[candidateId, reason.slice(0, 2000)]
);
}
// ---------------------------------------------------------------
// E.4 — Probation lifecycle
// ---------------------------------------------------------------
@@ -226,13 +263,18 @@ export class DiscoveryService {
if (completedPlays >= 3) {
await this.db.pgClient.query(
`UPDATE tracks SET probation_status = 'retained' WHERE id = $1`,
// Retained recommendations become normal library candidates. Their
// source_type remains RECOMMENDATION for provenance, while state=LIBRARY
// is the explicit promotion gate consumed by the existing Vibe queries.
`UPDATE tracks
SET probation_status = 'retained', state = 'LIBRARY'
WHERE id = $1`,
[trackId]
);
const claimRes = await this.db.pgClient.query<{ source: string }>(
`SELECT source FROM claims
WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'discovery_candidate'
WHERE subject_type = 'track' AND subject_id = $1 AND predicate = 'acquired_from'
LIMIT 1`,
[trackId]
);
@@ -252,7 +294,9 @@ export class DiscoveryService {
if (completedPlays === 0 && skips >= 3 && daysSinceProbation > 7) {
await this.db.pgClient.query(
`UPDATE tracks SET probation_status = 'retired' WHERE id = $1`,
// Retirement is reversible and never unlinks a file. The existing
// cleanup hard-delete workflow remains separately gated.
`UPDATE tracks SET probation_status = 'retired', state = 'HIDDEN' WHERE id = $1`,
[trackId]
);
return 'retired';
@@ -286,7 +330,7 @@ export class DiscoveryService {
`SELECT c.source, COUNT(*)::int AS cnt
FROM claims c
JOIN tracks t ON t.id = c.subject_id
WHERE c.predicate = 'discovery_candidate'
WHERE c.predicate = 'acquired_from'
AND t.probation_status = 'retained'
GROUP BY c.source
ORDER BY cnt DESC`
@@ -295,3 +339,11 @@ export class DiscoveryService {
console.log('[MetaLearning] Discovery source retention counts:', JSON.stringify(res.rows));
}
}
function safeJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
+110 -23
View File
@@ -15,11 +15,46 @@ export interface ClaimEdge {
fusedValue: number;
}
export interface VibeCalendarContext {
localHour: number;
weekday: number;
month: number;
timeZone?: string;
}
/** A bounded key for short-lived, calendar-specific preference beliefs. */
export function calendarContextKey(context: VibeCalendarContext): string {
const daypart = context.localHour < 6 ? 'night'
: context.localHour < 12 ? 'morning'
: context.localHour < 18 ? 'day'
: 'evening';
const dayType = context.weekday === 0 || context.weekday === 6 ? 'weekend' : 'weekday';
const season = context.month === 12 || context.month <= 2 ? 'winter'
: context.month <= 5 ? 'spring'
: context.month <= 8 ? 'summer'
: 'autumn';
return `calendar:${daypart}:${dayType}:${season}`;
}
export interface Candidate {
trackId: string;
generatorId: string;
explanation: ClaimEdge[];
relevance: number;
/**
* Filled by the session director after sequence planning. Generators remain
* deliberately unaware of slots and objectives, while the durable plan can
* retain why this particular candidate won its position.
*/
plan?: {
slotRole: string;
score: number;
scoreBreakdown: Record<string, unknown>;
explanation: Record<string, unknown>;
/** Revision-level policy and constraint evidence, copied into the durable
* objective snapshot by the coordinator. */
objective?: Record<string, unknown>;
};
}
export interface GeneratorContext {
@@ -33,8 +68,12 @@ export interface GeneratorContext {
energy: number;
lastArtistIds: string[];
lastGenreIds: string[];
context: string | null;
context: VibeCalendarContext | null;
noveltyHunger: number;
/** Session-local exploration controls; they never overwrite durable taste. */
explorationCoefficient?: number;
discoveryRadius?: number;
sessionGoal?: { type: string; target: number; progress: number };
sessionAgeMin: number;
};
}
@@ -42,6 +81,9 @@ export interface GeneratorContext {
export type Generator = (db: DbService, ctx: GeneratorContext) => Promise<Candidate[]>;
const OBJECTIVE_USER = '00000000-0000-0000-0000-000000000000';
const FALLBACK_CANDIDATE_LIMIT = 60;
/** How deep into a comfort artist's catalogue the two proposed tracks are drawn from. */
const COMFORT_ARTIST_POOL = 10;
// ---------------------------------------------------------------------------
// 1. COMFORT — Top artists by longterm affinity > 0.5
@@ -57,22 +99,31 @@ async function comfortGenerator(db: DbService, ctx: GeneratorContext): Promise<C
const artistValueMap = new Map(topArtists.map(b => [b.entity_id, b.value]));
const artistIds = topArtists.map(b => b.entity_id);
// Two tracks per artist, drawn at random from that artist's best known
// COMFORT_ARTIST_POOL. Taking the top two by fused value instead proposed the
// same handful of tracks in every session for as long as the listener's top
// artists held still, which is most of why a Vibe felt like it never moved.
const res = await db.pgClient.query(
`SELECT sub.id, sub.artist_id
`SELECT sampled.id, sampled.artist_id
FROM (
SELECT t.id, cf.object_id AS artist_id,
ROW_NUMBER() OVER (PARTITION BY cf.object_id ORDER BY cf.fused_value DESC) AS rn
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
AND NOT (t.id = ANY($4::uuid[]))
) sub
WHERE sub.rn <= 2
ORDER BY sub.artist_id, sub.rn`,
SELECT ranked.id, ranked.artist_id,
ROW_NUMBER() OVER (PARTITION BY ranked.artist_id ORDER BY RANDOM()) AS pick
FROM (
SELECT t.id, cf.object_id AS artist_id,
ROW_NUMBER() OVER (PARTITION BY cf.object_id ORDER BY cf.fused_value DESC) AS rn
FROM tracks t
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($4::uuid[]))
) ranked
WHERE ranked.rn <= ${COMFORT_ARTIST_POOL}
) sampled
WHERE sampled.pick <= 2
ORDER BY sampled.artist_id, sampled.pick`,
[artistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
);
@@ -141,7 +192,7 @@ async function adjacentGenerator(db: DbService, ctx: GeneratorContext): Promise<
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($4::uuid[]))
) sub
ORDER BY RANDOM()
@@ -209,7 +260,7 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
AND cf.predicate IN ('credited_main_on', 'featured_on')
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
WHERE t.state = 'LIBRARY'
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($2::uuid[]))
ORDER BY t.id, cf.fused_value DESC NULLS LAST
) sub
@@ -273,7 +324,7 @@ async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<
ROW_NUMBER() OVER (PARTITION BY t.album_id ORDER BY t.title ASC) AS rn
FROM tracks t
WHERE t.album_id = ANY($1::uuid[])
AND t.state = 'LIBRARY'
AND (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($2::uuid[]))
) sub
WHERE sub.rn <= 5
@@ -333,7 +384,7 @@ async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<C
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($4::uuid[]))
ORDER BY t.id, cf.fused_value DESC NULLS LAST
) sub
@@ -385,7 +436,7 @@ async function noveltyGenerator(db: DbService, ctx: GeneratorContext): Promise<C
AND cf_track.object_id = cf_edge.object_id
WHERE t.release_date IS NOT NULL
AND t.release_date >= NOW() - INTERVAL '60 days'
AND t.state = 'LIBRARY'
AND (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($1::uuid[]))
) sub
ORDER BY release_date DESC
@@ -435,7 +486,7 @@ async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Prom
FROM tracks t
JOIN track_genre tg ON tg.track_id = t.id
JOIN unfamiliar_genres ug ON ug.id = tg.genre_id
WHERE t.state = 'LIBRARY'
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($2::uuid[]))
LIMIT 30
)
@@ -468,6 +519,8 @@ async function experimentalGenerator(db: DbService, ctx: GeneratorContext): Prom
async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
if (!ctx.state.context) return [];
const contextKey = calendarContextKey(ctx.state.context);
const contextualBeliefs = await db.getListenerBeliefs({
userId: ctx.userId,
profile: 'contextual',
@@ -476,7 +529,9 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
order: 'DESC',
});
const targetBeliefs = contextualBeliefs.filter(b => b.entity_type === 'artist' && b.value > 0.2);
const targetBeliefs = contextualBeliefs.filter(b =>
b.entity_type === 'artist' && b.dimension === contextKey && b.value > 0.2,
);
const targetArtistIds = targetBeliefs.map(b => b.entity_id);
const targetValueMap = new Map(targetBeliefs.map(b => [b.entity_id, b.value]));
@@ -491,7 +546,7 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
AND cf.object_type = 'artist'
AND cf.object_id = ANY($1::uuid[])
AND (cf.user_id = $2 OR cf.user_id = $3)
WHERE t.state = 'LIBRARY'
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($4::uuid[]))
ORDER BY t.id, cf.fused_value DESC NULLS LAST
) sub
@@ -518,6 +573,37 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
});
}
// ---------------------------------------------------------------------------
// 9. LIBRARY FALLBACK — keeps a Vibe usable when the metadata graph is sparse
// ---------------------------------------------------------------------------
async function libraryFallbackGenerator(db: DbService, ctx: GeneratorContext): Promise<Candidate[]> {
const res = await db.pgClient.query(
// Probation belongs here too. This is the one generator that does not walk
// the graph, so it is the only way an acquired recommendation nothing has
// enriched yet can ever be offered — which is why Found tracks were sitting
// on disk unplayed.
`SELECT t.id
FROM tracks t
WHERE (t.state = 'LIBRARY' OR (t.state = 'RECOMMENDED' AND t.probation_status = 'probation'))
AND NOT (t.id = ANY($1::uuid[]))
ORDER BY RANDOM()
LIMIT $2`,
[ctx.recentExclusions, FALLBACK_CANDIDATE_LIMIT],
);
return (res.rows as { id: string }[]).map(row => ({
trackId: row.id,
generatorId: 'library-fallback',
relevance: 0.05,
explanation: [{
subjectType: 'track', subjectId: row.id,
predicate: 'library_fallback',
objectType: 'track', objectId: row.id,
fusedValue: 0.05,
}],
}));
}
// ---------------------------------------------------------------------------
// All generators, ordered by priority (comfort first, experimental last)
// ---------------------------------------------------------------------------
@@ -530,4 +616,5 @@ export const ALL_GENERATORS: Generator[] = [
noveltyGenerator,
contextualGenerator,
experimentalGenerator,
libraryFallbackGenerator,
];
+70 -9
View File
@@ -53,6 +53,34 @@ describe('generators', () => {
expect(results[0].explanation.length).toBeGreaterThanOrEqual(1);
});
it('accepts the fresh artist affinity shape produced by a promoted track', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'track-1', artist_id: 'artist-1' }] });
// recordTrackEvidence(add_to_favorites) projects 0.60 * 0.90 = 0.54
// onto a fresh longterm artist affinity belief.
const ctx = makeCtx({ beliefs: [
{ entity_type: 'artist', entity_id: 'artist-1', value: 0.54, confidence: 0.05, profile: 'longterm', dimension: 'affinity' } as any,
] });
const results = await generatorByName.comfort(db, ctx);
expect(results).toHaveLength(1);
expect(results[0].generatorId).toBe('comfort');
});
it('samples two tracks at random from each artist rather than fixing on their top two', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [] });
const ctx = makeCtx({ beliefs: [
{ entity_type: 'artist', entity_id: 'artist-1', value: 0.8, profile: 'longterm', dimension: 'affinity' } as any,
] });
await generatorByName.comfort(db, ctx);
const [sql] = (db.pgClient.query as any).mock.calls[0];
expect(sql).toContain('ORDER BY RANDOM()');
expect(sql).toContain('ranked.rn <= 10');
expect(sql).toContain('sampled.pick <= 2');
});
it('returns empty when no high-affinity artists', async () => {
const db = makeMockDb();
const ctx = makeCtx({ beliefs: [ { entity_type: 'artist', entity_id: 'a1', value: 0.3, profile: 'longterm', dimension: 'affinity' } as any ] });
@@ -128,16 +156,43 @@ describe('generators', () => {
});
});
describe('novelty', () => {
it('returns recent tracks', async () => {
it('returns a candidate when canonical release-date coverage makes a recent track eligible', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 't1' }] });
const ctx = makeCtx({
beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ],
});
const results = await generatorByName.novelty(db, ctx);
if (results.length > 0) {
expect(results[0].generatorId).toBe('novelty');
}
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({ trackId: 't1', generatorId: 'novelty' });
});
it('queries only dated library tracks from the current novelty window', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [] });
const ctx = makeCtx({
beliefs: [ { entity_type: 'artist', entity_id: 'trusted-1', value: 0.5, profile: 'longterm', dimension: 'affinity' } as any ],
});
await generatorByName.novelty(db, ctx);
const [sql] = (db.pgClient.query as any).mock.calls[0];
expect(sql).toContain('t.release_date IS NOT NULL');
expect(sql).toContain("t.release_date >= NOW() - INTERVAL '60 days'");
expect(sql).toContain("t.state = 'LIBRARY'");
});
});
describe('library fallback', () => {
it('offers probation recommendations, which no graph generator can reach yet', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [{ id: 'probation-1' }] });
const results = await ALL_GENERATORS[8](db, makeCtx());
const [sql] = (db.pgClient.query as any).mock.calls[0];
expect(sql).toContain("t.state = 'RECOMMENDED' AND t.probation_status = 'probation'");
expect(results[0]).toMatchObject({ trackId: 'probation-1', generatorId: 'library-fallback' });
});
});
@@ -155,13 +210,19 @@ describe('generators', () => {
describe('contextual', () => {
it('returns tracks matching context when set', async () => {
const db = makeMockDb();
const db = makeMockDb({
getListenerBeliefs: vi.fn().mockResolvedValue([
{ entity_type: 'artist', entity_id: 'artist-1', value: 0.7, profile: 'contextual', dimension: 'calendar:day:weekday:summer' },
{ entity_type: 'artist', entity_id: 'artist-2', value: 0.9, profile: 'contextual', dimension: 'calendar:night:weekend:summer' },
]),
});
(db.pgClient.query as any).mockResolvedValueOnce({ rows: [{ id: 't1' }] });
const ctx = makeCtx({
state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: 'coding', noveltyHunger: 0.3, sessionAgeMin: 10 },
state: { energy: 0.5, lastArtistIds: [], lastGenreIds: [], context: { localHour: 12, weekday: 1, month: 6 }, noveltyHunger: 0.3, sessionAgeMin: 10 },
});
const results = await generatorByName.contextual(db, ctx);
expect(results).toBeDefined();
expect(results).toHaveLength(1);
expect((db.pgClient.query as any).mock.calls[0][1][0]).toEqual(['artist-1']);
});
it('returns empty when no context set', async () => {
@@ -172,8 +233,8 @@ describe('generators', () => {
});
describe('ALL_GENERATORS', () => {
it('contains 8 generators', () => {
expect(ALL_GENERATORS).toHaveLength(8);
it('contains 9 generators', () => {
expect(ALL_GENERATORS).toHaveLength(9);
ALL_GENERATORS.forEach(g => expect(typeof g).toBe('function'));
});
});
+161 -27
View File
@@ -1,5 +1,12 @@
import { Queue, Job } from 'bullmq';
import { MetadataRefreshJob, AudioAnalysisJob, CleanupJob, LibraryScanJob, ReindexTracksJob, ReprocessArtistsJob } from '../types/job.types.js';
import { MetadataRefreshJob, AudioAnalysisJob, CleanupJob, LibraryScanJob, ReindexTracksJob, ReprocessArtistsJob, AcquisitionJob } from '../types/job.types.js';
const AUDIO_ANALYSIS_JOB_OPTIONS = {
attempts: 3,
backoff: { type: 'exponential', delay: 5_000 },
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 10_000 },
removeOnFail: { age: 30 * 24 * 60 * 60, count: 10_000 },
} as const;
export interface JobServiceConfig {
redisUrl: string;
@@ -26,6 +33,37 @@ export interface JobHistoryEntry {
returnvalue?: unknown;
}
export interface ReenrichmentQueuePlan {
metadataTrackIds: string[];
artistIds: string[];
albumIds: string[];
}
export interface EnqueueResult {
requested: number;
enqueued: number;
alreadyQueued: number;
failed: Array<{ id: string; error: string }>;
}
export interface ReenrichmentQueueResult {
metadata: EnqueueResult;
artistImages: EnqueueResult;
albumCovers: EnqueueResult;
}
export interface EnrichmentQueueDiagnostics {
pending: Record<string, number>;
recentFailures: Array<{
id: string;
name: string;
data: Record<string, unknown>;
failedReason?: string;
timestamp: number;
finishedOn?: number;
}>;
}
export class JobService {
private queue: Queue;
@@ -42,9 +80,12 @@ export class JobService {
await this.queue.add('metadata_refresh', payload);
}
async enqueueAudioAnalysis(trackId: string, features: string[]) {
const payload: AudioAnalysisJob = { trackId, features };
await this.queue.add('audio_analysis', payload);
async enqueueAudioAnalysis(trackId: string) {
const payload: AudioAnalysisJob = { trackId };
await this.queue.add('audio_analysis', payload, {
jobId: `audio-${trackId}`,
...AUDIO_ANALYSIS_JOB_OPTIONS,
});
}
async enqueueCleanup(reason: 'expired' | 'manual', targetFiles: string[]) {
@@ -67,30 +108,123 @@ export class JobService {
await this.queue.add('reprocess_artists', payload);
}
/**
* Enqueue metadata_refresh jobs for a batch of track IDs. Used by the
* /admin/reenrich-tracks endpoint to re-canonicalize metadata (artist names,
* album titles, MBIDs, cover art) without re-scanning files from disk.
*
* Each job is deduped by `jobId: meta-<trackId>` so re-running the endpoint
* doesn't stack duplicate jobs. Old completed/failed jobs with the same ID
* are removed first so re-enrichment actually works (BullMQ otherwise treats
* existing jobIds as duplicates and silently skips them).
*/
async enqueueMetadataRefreshBatch(trackIds: string[]): Promise<number> {
let enqueued = 0;
for (const trackId of trackIds) {
const jobId = `meta-${trackId}`;
await this.queue.remove(jobId).catch(() => {});
const payload: MetadataRefreshJob = { trackId, refreshType: 'full' };
await this.queue.add('metadata_refresh', payload, {
jobId,
removeOnComplete: { age: 86400, count: 10000 },
removeOnFail: { age: 86400, count: 10000 },
});
enqueued++;
async enqueueDiscoveryAcquisition(candidateId: string): Promise<void> {
const payload: AcquisitionJob = { candidateId };
const jobId = `acquire-${candidateId}`;
// BullMQ returns a retained completed/failed job for the same id without
// executing it. Remove terminal attempts before retrying so a candidate
// cannot be stranded in `acquiring` for the retention window.
const existing = await this.queue.getJob(jobId);
if (existing) {
const state = await existing.getState();
if (state === 'completed' || state === 'failed') await existing.remove();
}
return enqueued;
await this.queue.add('acquire_discovery_candidate', payload, {
// A candidate may have one active attempt. Failed/completed jobs expire
// so an operator can explicitly re-evaluate it later.
jobId,
removeOnComplete: { age: 86400, count: 1000 },
removeOnFail: { age: 86400, count: 1000 },
});
}
/**
* Queue a bounded re-enrichment plan. Metadata, artist images and album
* covers deliberately have separate jobs: metadata resolution can complete
* even when artwork providers are unavailable, and artwork is deduplicated
* at its natural artist/album boundary.
*
* Existing active/waiting jobs are preserved and reported as alreadyQueued.
* Completed/failed jobs are removed before retrying, so a request never
* claims a retry was enqueued when BullMQ retained an old job id.
*/
async enqueueReenrichment(plan: ReenrichmentQueuePlan): Promise<ReenrichmentQueueResult> {
return {
metadata: await this.enqueueMany(
[...new Set(plan.metadataTrackIds)],
'metadata_refresh',
(trackId) => ({ trackId, refreshType: 'full' }),
(trackId) => `meta-${trackId}`,
),
artistImages: await this.enqueueMany(
[...new Set(plan.artistIds)],
'artist_image',
(artistId) => ({ artistId }),
(artistId) => `artist-image-${artistId}`,
),
albumCovers: await this.enqueueMany(
[...new Set(plan.albumIds)],
'album_cover',
(albumId) => ({ albumId }),
(albumId) => `album-cover-${albumId}`,
),
};
}
private async enqueueMany(
ids: string[],
name: string,
payloadFor: (id: string) => Record<string, unknown>,
jobIdFor: (id: string) => string,
): Promise<EnqueueResult> {
const result: EnqueueResult = { requested: ids.length, enqueued: 0, alreadyQueued: 0, failed: [] };
const keep = {
removeOnComplete: { age: 86400, count: 10000 },
removeOnFail: { age: 86400, count: 10000 },
} as const;
for (const id of ids) {
const jobId = jobIdFor(id);
try {
const existing = await this.queue.getJob(jobId);
if (existing) {
const state = await existing.getState();
if (state === 'active' || state === 'waiting' || state === 'delayed' || state === 'prioritized') {
result.alreadyQueued++;
continue;
}
await existing.remove();
}
await this.queue.add(name, payloadFor(id), { jobId, ...keep });
result.enqueued++;
} catch (err) {
result.failed.push({ id, error: err instanceof Error ? err.message : String(err) });
}
}
return result;
}
async getEnrichmentQueueDiagnostics(limit = 25): Promise<EnrichmentQueueDiagnostics> {
const relevant = new Set(['metadata_refresh', 'artist_image', 'album_cover']);
const [waiting, active, delayed, failed] = await Promise.all([
this.queue.getJobs(['waiting'], 0, 1000),
this.queue.getJobs(['active'], 0, 1000),
this.queue.getJobs(['delayed'], 0, 1000),
this.queue.getJobs(['failed'], 0, Math.max(limit * 4, 100)),
]);
const pending: Record<string, number> = {
metadata_refresh: 0,
artist_image: 0,
album_cover: 0,
};
for (const job of [...waiting, ...active, ...delayed]) {
if (relevant.has(job.name)) pending[job.name]++;
}
const recentFailures = failed
.filter((job) => relevant.has(job.name))
.sort((a, b) => (b.finishedOn ?? b.timestamp) - (a.finishedOn ?? a.timestamp))
.slice(0, limit)
.map((job) => ({
id: String(job.id),
name: job.name,
data: job.data as Record<string, unknown>,
failedReason: job.failedReason,
timestamp: job.timestamp,
finishedOn: job.finishedOn,
}));
return { pending, recentFailures };
}
async getQueueStats(): Promise<QueueStats> {
@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest';
import type { Pool } from 'pg';
import {
NotSessionOwnerError,
PlaybackEvent,
PlaybackSyncService,
} from './playback-sync.service.js';
const USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const DESKTOP = '11111111-1111-4111-8111-111111111111';
const PHONE = '22222222-2222-4222-8222-222222222222';
const SESSION = '33333333-3333-4333-8333-333333333333';
/**
* A pool stubbed down to the one row this service reasons about. The queries
* themselves are plain SQL against a single table, so what is worth testing is
* the ownership and fan-out logic sitting on top of them.
*/
function poolWith(state: {
deviceId?: string | null;
trackId?: string | null;
vibeSessionId?: string | null;
isPlaying?: boolean;
positionMs?: number;
}) {
const row = {
device_id: state.deviceId ?? null,
track_id: state.trackId ?? null,
vibe_session_id: state.vibeSessionId ?? null,
queue: [],
queue_index: -1,
position_ms: state.positionMs ?? 0,
is_playing: state.isPlaying ?? false,
version: '4',
updated_at: new Date('2026-08-08T12:00:00Z'),
};
const queries: string[] = [];
const pool = {
async query(rawSql: string, params?: unknown[]) {
queries.push(rawSql);
// The service formats its SQL in columns, so match on collapsed text.
const sql = rawSql.replace(/\s+/g, ' ').trim();
if (sql.includes('FROM playback_devices') && sql.includes('ORDER BY')) {
return { rows: [{ id: DESKTOP, name: 'Linux · Firefox', last_seen_at: row.updated_at, online: true }], rowCount: 1 };
}
if (sql.startsWith('UPDATE playback_devices SET name')) {
return { rows: [{ id: params?.[0], name: params?.[2], last_seen_at: row.updated_at }], rowCount: 1 };
}
if (sql.startsWith('INSERT INTO playback_devices')) {
return { rows: [{ id: PHONE, name: params?.[1], last_seen_at: row.updated_at }], rowCount: 1 };
}
if (sql.startsWith('SELECT device_id FROM playback_state')) {
return { rows: [{ device_id: row.device_id }], rowCount: 1 };
}
if (sql.includes('SELECT id FROM playback_devices')) {
return { rows: [{ id: params?.[0] }], rowCount: 1 };
}
if (sql.includes('UPDATE playback_state') && sql.includes('device_id = $2')) {
row.device_id = String(params?.[1]);
return { rows: [row], rowCount: 1 };
}
if (sql.includes('UPDATE playback_state')) {
return { rows: [row], rowCount: 1 };
}
return { rows: [row], rowCount: 1 };
},
} as unknown as Pool;
return { pool, queries, row };
}
describe('cross-device playback', () => {
it('refuses a state report from a device that no longer holds the audio', async () => {
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
await expect(service.reportState(USER_ID, PHONE, { isPlaying: true }))
.rejects.toBeInstanceOf(NotSessionOwnerError);
});
it('accepts the first device to report on an unowned session', async () => {
const { pool } = poolWith({ deviceId: null });
const service = new PlaybackSyncService(pool);
const state = await service.reportState(USER_ID, PHONE, { isPlaying: true });
expect(state.deviceId).toBe(PHONE);
});
it('publishes the Vibe session the owning device is driving', async () => {
// Vibe control follows the audio, and this is how the next owner hears about
// the session it is taking over.
const { pool } = poolWith({ deviceId: DESKTOP, vibeSessionId: SESSION });
const service = new PlaybackSyncService(pool);
expect((await service.getState(USER_ID)).vibeSessionId).toBe(SESSION);
});
it('keeps a device its stored id across a reload', async () => {
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
const device = await service.registerDevice(USER_ID, 'Linux · Firefox', DESKTOP);
expect(device.id).toBe(DESKTOP);
});
it('gives a second tab a device of its own', async () => {
// Both tabs of a browser ask with the same stored id, and one id shared by
// two pages is one device that runs every command twice.
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
service.claimStream(USER_ID, DESKTOP);
const device = await service.registerDevice(USER_ID, 'Linux · Firefox', DESKTOP);
expect(device.id).toBe(PHONE);
});
it('delivers a command to the owning device only', async () => {
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
const seen: PlaybackEvent[] = [];
service.subscribe(USER_ID, (event) => seen.push(event));
service.claimStream(USER_ID, DESKTOP);
const result = await service.sendCommand(USER_ID, { type: 'pause' });
expect(result.deliveredTo).toBe(DESKTOP);
expect(seen).toContainEqual({ type: 'command', deviceId: DESKTOP, command: { type: 'pause' } });
});
it('reports no delivery when the owning device has no stream to receive on', async () => {
// Ownership outlives a closed stream, so a killed tab still holds the
// session. Answering 202 there told the presser a lie.
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
expect(await service.sendCommand(USER_ID, { type: 'pause' })).toEqual({ deliveredTo: null });
});
it('frees a device id once its stream closes, and not before', async () => {
const { pool } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
const release = service.claimStream(USER_ID, DESKTOP);
const alsoOpen = service.claimStream(USER_ID, DESKTOP);
release();
expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(true);
alsoOpen();
expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(false);
// A close arriving twice must not free an id a later stream is holding.
release();
expect(service.hasLiveStream(USER_ID, DESKTOP)).toBe(false);
});
it('reports no delivery when nothing holds the audio', async () => {
const { pool } = poolWith({ deviceId: null });
const service = new PlaybackSyncService(pool);
expect(await service.sendCommand(USER_ID, { type: 'play' })).toEqual({ deliveredTo: null });
});
it('hands the session to another device without losing the position', async () => {
const { pool } = poolWith({ deviceId: DESKTOP, trackId: 'track-1', positionMs: 45_000, isPlaying: true });
const service = new PlaybackSyncService(pool);
const seen: PlaybackEvent[] = [];
service.subscribe(USER_ID, (event) => seen.push(event));
const state = await service.transfer(USER_ID, PHONE);
expect(state.deviceId).toBe(PHONE);
expect(state.position).toBe(45);
expect(state.isPlaying).toBe(true);
expect(seen.some((event) => event.type === 'state')).toBe(true);
});
it('frees ownership when the owning device goes away', async () => {
const { pool, queries } = poolWith({ deviceId: DESKTOP });
const service = new PlaybackSyncService(pool);
await service.releaseIfOwner(USER_ID, DESKTOP);
expect(queries.some((sql) => sql.includes('SET device_id = NULL'))).toBe(true);
});
});
@@ -0,0 +1,378 @@
import { EventEmitter } from 'node:events';
import type { Pool } from 'pg';
/**
* Cross-device playback: one authoritative session per listener, any number of
* devices watching it, exactly one of them holding the audio.
*
* Two channels do the work. `state` carries the snapshot every device renders,
* so a phone that just opened shows what the desktop is playing. `command`
* carries an instruction aimed at the owning device only, because the audio
* element lives there and nowhere else. Handoff is a state change like any
* other: the new owner starts at the position the old one reported, and the old
* one stops when it sees it no longer owns the session.
*
* ponytail: the fan-out is an in-process EventEmitter, which is correct for the
* single backend container this deploys as. A second instance would need Redis
* pub/sub here — the rest of the design already assumes nothing else.
*/
/** A device is offline once it stops sending its stream heartbeat. */
export const DEVICE_STALE_MS = 90_000;
/** Queue snapshots are capped: this is a handoff payload, not a playlist store. */
export const MAX_SYNCED_QUEUE = 100;
export type PlaybackCommandType = 'play' | 'pause' | 'next' | 'prev' | 'seek' | 'play_track';
export interface PlaybackCommand {
type: PlaybackCommandType;
/** Seconds into the current track. Only meaningful for `seek`. */
position?: number;
/** Only meaningful for `play_track`. */
trackId?: string;
}
export interface PlaybackDevice {
id: string;
name: string;
lastSeenAt: string;
online: boolean;
isOwner: boolean;
}
export interface PlaybackSnapshot {
deviceId: string | null;
trackId: string | null;
/**
* The durable Vibe session the owning device is playing, when it is playing
* one. Whichever device holds the audio drives the session, so this is how the
* next owner learns there is one to adopt.
*/
vibeSessionId: string | null;
queue: unknown[];
queueIndex: number;
position: number;
isPlaying: boolean;
version: number;
updatedAt: string;
}
export interface PlaybackStatePatch {
trackId?: string | null;
vibeSessionId?: string | null;
queue?: unknown[];
queueIndex?: number;
position?: number;
isPlaying?: boolean;
}
export type PlaybackEvent =
| { type: 'state'; state: PlaybackSnapshot; devices: PlaybackDevice[] }
| { type: 'command'; deviceId: string; command: PlaybackCommand };
export class NotSessionOwnerError extends Error {
constructor() {
super('device does not own playback');
this.name = 'NotSessionOwnerError';
}
}
type StateRow = {
device_id: string | null;
track_id: string | null;
vibe_session_id: string | null;
queue: unknown[];
queue_index: number;
position_ms: number;
is_playing: boolean;
version: string;
updated_at: Date;
};
function toSnapshot(row: StateRow): PlaybackSnapshot {
return {
deviceId: row.device_id,
trackId: row.track_id,
vibeSessionId: row.vibe_session_id ?? null,
queue: Array.isArray(row.queue) ? row.queue : [],
queueIndex: row.queue_index,
position: row.position_ms / 1000,
isPlaying: row.is_playing,
version: Number(row.version),
updatedAt: row.updated_at.toISOString(),
};
}
function clampPositionMs(position: number | undefined, fallback: number): number {
if (typeof position !== 'number' || !Number.isFinite(position) || position < 0) return fallback;
return Math.min(Math.round(position * 1000), 24 * 60 * 60 * 1000);
}
export class PlaybackSyncService {
private readonly emitter = new EventEmitter();
/**
* Open streams per device, keyed `userId:deviceId`. A device only exists as
* far as this session is concerned while it is holding one: that is what
* decides whether a command can be delivered, and whether a browser asking to
* reuse a stored device id would be colliding with a tab that already has it.
* Counted rather than a flag, so a reconnect racing its own close cannot leave
* a device permanently marked busy.
*/
private readonly streams = new Map<string, number>();
constructor(private readonly pgPool: Pool) {
// One session with many idle tabs is the normal case, and Node warns at ten
// listeners on the assumption they are a leak. They are not.
this.emitter.setMaxListeners(0);
}
subscribe(userId: string, listener: (event: PlaybackEvent) => void): () => void {
this.emitter.on(userId, listener);
return () => this.emitter.off(userId, listener);
}
/** Mark a device's stream open until the returned function is called. */
claimStream(userId: string, deviceId: string): () => void {
const key = `${userId}:${deviceId}`;
this.streams.set(key, (this.streams.get(key) ?? 0) + 1);
let released = false;
return () => {
if (released) return;
released = true;
const open = (this.streams.get(key) ?? 1) - 1;
if (open > 0) this.streams.set(key, open);
else this.streams.delete(key);
};
}
hasLiveStream(userId: string, deviceId: string): boolean {
return (this.streams.get(`${userId}:${deviceId}`) ?? 0) > 0;
}
private async publish(userId: string): Promise<void> {
const [state, devices] = await Promise.all([this.getState(userId), this.listDevices(userId)]);
this.emitter.emit(userId, { type: 'state', state, devices } satisfies PlaybackEvent);
}
/**
* Register a device, or refresh one the browser already knows about. The
* caller supplies the id it stored locally so a reload keeps its identity and
* the device list does not grow one row per page load.
*
* A second tab of the same browser asks with the same stored id, and two tabs
* sharing one device id are one device that runs every command twice and plays
* two copies of the audio. An id whose stream is already open therefore does
* not get reused: the caller is given a device of its own instead.
*/
async registerDevice(userId: string, name: string, deviceId?: string | null): Promise<PlaybackDevice> {
const cleanName = (name || 'Unknown device').trim().slice(0, 120) || 'Unknown device';
if (deviceId && !this.hasLiveStream(userId, deviceId)) {
const updated = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date }>(
`UPDATE playback_devices SET name = $3, last_seen_at = NOW()
WHERE id = $1 AND user_id = $2
RETURNING id, name, last_seen_at`,
[deviceId, userId, cleanName]
);
if (updated.rows[0]) {
const owner = await this.ownerId(userId);
await this.publish(userId);
return {
id: updated.rows[0].id,
name: updated.rows[0].name,
lastSeenAt: updated.rows[0].last_seen_at.toISOString(),
online: true,
isOwner: owner === updated.rows[0].id,
};
}
}
const inserted = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date }>(
`INSERT INTO playback_devices (user_id, name) VALUES ($1, $2)
RETURNING id, name, last_seen_at`,
[userId, cleanName]
);
const row = inserted.rows[0];
await this.publish(userId);
return {
id: row.id,
name: row.name,
lastSeenAt: row.last_seen_at.toISOString(),
online: true,
isOwner: false,
};
}
async touchDevice(userId: string, deviceId: string): Promise<void> {
await this.pgPool.query(
`UPDATE playback_devices SET last_seen_at = NOW() WHERE id = $1 AND user_id = $2`,
[deviceId, userId]
);
}
async listDevices(userId: string): Promise<PlaybackDevice[]> {
const owner = await this.ownerId(userId);
const res = await this.pgPool.query<{ id: string; name: string; last_seen_at: Date; online: boolean }>(
`SELECT id, name, last_seen_at,
last_seen_at > NOW() - ($2::int * INTERVAL '1 millisecond') AS online
FROM playback_devices
WHERE user_id = $1
ORDER BY last_seen_at DESC`,
[userId, DEVICE_STALE_MS]
);
return res.rows.map((row) => ({
id: row.id,
name: row.name,
lastSeenAt: row.last_seen_at.toISOString(),
online: row.online,
isOwner: row.id === owner,
}));
}
private async ownerId(userId: string): Promise<string | null> {
const res = await this.pgPool.query<{ device_id: string | null }>(
'SELECT device_id FROM playback_state WHERE user_id = $1', [userId]
);
return res.rows[0]?.device_id ?? null;
}
async getState(userId: string): Promise<PlaybackSnapshot> {
const res = await this.pgPool.query<StateRow>(
`INSERT INTO playback_state (user_id) VALUES ($1)
ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id
RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
[userId]
);
return toSnapshot(res.rows[0]);
}
/**
* Record what the owning device is doing. A device that does not own the
* session is rejected rather than ignored, so a stale tab resuming from sleep
* learns it lost the audio instead of silently fighting the current owner.
*/
async reportState(userId: string, deviceId: string, patch: PlaybackStatePatch): Promise<PlaybackSnapshot> {
const current = await this.getState(userId);
if (current.deviceId !== null && current.deviceId !== deviceId) throw new NotSessionOwnerError();
const queue = patch.queue === undefined
? undefined
: patch.queue.slice(0, MAX_SYNCED_QUEUE);
// A track deleted between the device reading it and reporting it would
// otherwise fail the foreign key and take the whole report down with it.
// The session is worth more than the pointer: keep the rest, drop the id.
const write = async (trackId: string | null) => this.pgPool.query<StateRow>(
`UPDATE playback_state
SET device_id = $2,
track_id = COALESCE($3, CASE WHEN $4 THEN NULL ELSE track_id END),
queue = COALESCE($5::jsonb, queue),
queue_index = COALESCE($6, queue_index),
position_ms = COALESCE($7, position_ms),
is_playing = COALESCE($8, is_playing),
-- Only the device driving the Vibe reports one, and it reports the
-- absence of one just as explicitly: ending a Vibe and playing an
-- album has to clear this, or the next owner would adopt a session
-- nothing is playing any more.
vibe_session_id = COALESCE($9, CASE WHEN $10 THEN NULL ELSE vibe_session_id END),
version = version + 1,
updated_at = NOW()
WHERE user_id = $1
RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
[
userId,
deviceId,
trackId,
patch.trackId === null,
queue === undefined ? null : JSON.stringify(queue),
patch.queueIndex ?? null,
patch.position === undefined ? null : clampPositionMs(patch.position, 0),
patch.isPlaying ?? null,
patch.vibeSessionId ?? null,
patch.vibeSessionId === null,
]
);
let res: Awaited<ReturnType<typeof write>>;
try {
res = await write(patch.trackId ?? null);
} catch (err) {
if ((err as { code?: string }).code !== '23503') throw err;
res = await write(null);
}
await this.touchDevice(userId, deviceId);
await this.publish(userId);
return toSnapshot(res.rows[0]);
}
/**
* Aim a command at whichever device holds the audio. Any device may send one,
* including the owner itself — that is what makes a phone a remote for the
* desktop without either side knowing which is which.
*
* Ownership outlives a closed stream, so the owner is not necessarily
* listening: a tab that was killed keeps the session until the sweep frees it.
* Emitting into that gap answered the presser with a success it did not get,
* so a command is only accepted while the owner has a stream to receive it on.
*/
async sendCommand(userId: string, command: PlaybackCommand): Promise<{ deliveredTo: string | null }> {
const owner = await this.ownerId(userId);
if (!owner || !this.hasLiveStream(userId, owner)) return { deliveredTo: null };
this.emitter.emit(userId, { type: 'command', deviceId: owner, command } satisfies PlaybackEvent);
return { deliveredTo: owner };
}
/**
* Move the audio to `deviceId`. The snapshot is untouched apart from the
* owner, so the new device resumes the same track at the same position, and
* the previous owner stops as soon as the state event reaches it.
*/
async transfer(userId: string, deviceId: string): Promise<PlaybackSnapshot> {
await this.getState(userId);
const device = await this.pgPool.query(
'SELECT id FROM playback_devices WHERE id = $1 AND user_id = $2', [deviceId, userId]
);
if (device.rows.length === 0) throw new Error('unknown device');
const res = await this.pgPool.query<StateRow>(
`UPDATE playback_state
SET device_id = $2, version = version + 1, updated_at = NOW()
WHERE user_id = $1
RETURNING device_id, track_id, vibe_session_id, queue, queue_index, position_ms, is_playing, version, updated_at`,
[userId, deviceId]
);
await this.publish(userId);
return toSnapshot(res.rows[0]);
}
/**
* Release ownership when the owning device goes away, leaving the snapshot
* intact so another device can pick the session up where it stopped.
*/
async releaseIfOwner(userId: string, deviceId: string): Promise<void> {
const res = await this.pgPool.query(
`UPDATE playback_state SET device_id = NULL, is_playing = FALSE,
version = version + 1, updated_at = NOW()
WHERE user_id = $1 AND device_id = $2`,
[userId, deviceId]
);
if (res.rowCount) await this.publish(userId);
}
/** Drop devices that have not been seen for a day, and free a stale owner. */
async reapStaleDevices(): Promise<number> {
const stale = await this.pgPool.query<{ user_id: string }>(
`UPDATE playback_state SET device_id = NULL, is_playing = FALSE,
version = version + 1, updated_at = NOW()
WHERE device_id IN (
SELECT id FROM playback_devices
WHERE last_seen_at < NOW() - ($1::int * INTERVAL '1 millisecond')
)
RETURNING user_id`,
[DEVICE_STALE_MS]
);
const removed = await this.pgPool.query(
`DELETE FROM playback_devices WHERE last_seen_at < NOW() - INTERVAL '1 day'`
);
for (const row of stale.rows) await this.publish(row.user_id);
return removed.rowCount ?? 0;
}
}
File diff suppressed because it is too large Load Diff
+798 -3
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { SessionDirector } from './session-director.service.js';
import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector, sessionSimilarityPenalty } from './session-director.service.js';
import { DbService } from './db.service.js';
import { ALL_GENERATORS } from './generators.service.js';
function makeMockDb(overrides: Record<string, any> = {}): DbService {
const mockQuery = vi.fn();
@@ -15,6 +16,76 @@ function makeMockDb(overrides: Record<string, any> = {}): DbService {
}
describe('SessionDirector', () => {
const candidate = (trackId: string) => ({
trackId,
generatorId: 'test',
relevance: 1,
explanation: [],
});
describe('session queue exclusions', () => {
it('keeps more than 100 unique selections without cycling', () => {
const source = Array.from({ length: 125 }, (_, index) => candidate(`track-${index}`));
const plan = mergeUniquePlan([], source, [], 125);
expect(plan).toHaveLength(125);
expect(new Set(plan.map(item => item.trackId)).size).toBe(125);
});
it('drops rapid skips and duplicate-only refill candidates', async () => {
const director = new SessionDirector(makeMockDb());
const buildPlan = vi.spyOn(director, 'buildPlan').mockResolvedValue([
candidate('skipped'),
candidate('already-queued'),
candidate('fresh'),
candidate('fresh'),
]);
const plan = await director.replan(
'user-1',
'session-1',
[candidate('skipped'), candidate('already-queued'), candidate('already-queued')],
['skipped'],
undefined,
{ excludedTrackIds: ['older-skip', 'skipped'] }
);
expect(plan.map(item => item.trackId)).toEqual(['already-queued', 'fresh']);
expect(buildPlan).toHaveBeenCalledWith(
'user-1',
'session-1',
undefined,
expect.objectContaining({
excludedTrackIds: expect.any(Set),
})
);
const refillExclusions = (buildPlan.mock.calls[0][3] as any).excludedTrackIds as Set<string>;
expect(refillExclusions).toEqual(new Set(['older-skip', 'skipped', 'already-queued']));
expect((buildPlan.mock.calls[0][3] as any).retainedPlan.map((item: any) => item.trackId))
.toEqual(['already-queued']);
});
it('does not append anything when a refill contains only queued or excluded tracks', async () => {
const director = new SessionDirector(makeMockDb());
vi.spyOn(director, 'buildPlan').mockResolvedValue([
candidate('queued'),
candidate('skipped'),
candidate('queued'),
]);
const plan = await director.replan(
'user-1',
'session-1',
[candidate('queued')],
['skipped'],
undefined,
{ excludedTrackIds: ['skipped'] }
);
expect(plan.map(item => item.trackId)).toEqual(['queued']);
});
});
describe('pickArc', () => {
const director = new SessionDirector(makeMockDb());
@@ -51,9 +122,261 @@ describe('SessionDirector', () => {
it('has valid role names', () => {
const slots = director.getArcSlots('comfort', 20);
const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow'];
const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow', 'surprise'];
slots.forEach(s => expect(validRoles).toContain(s.role));
});
it('creates measurable targets, callbacks, and one bounded surprise in the first arc cycle', () => {
const slots = director.getArcSlots('comfort', 20);
expect(slots.every(slot => slot.targets && Object.keys(slot.targets).length > 0)).toBe(true);
expect(slots.filter(slot => slot.surprise)).toHaveLength(1);
const anchor = slots.find(slot => slot.callback?.phase === 'anchor');
const callback = slots.find(slot => slot.callback?.phase === 'return');
expect(anchor?.callback?.id).toBe(callback?.callback?.id);
expect(anchor?.callback?.minSeparation).toBeGreaterThan(0);
});
});
describe('durable surprise delivery accounting', () => {
it('counts only exact, served surprise plan-item exposures', async () => {
const sessionId = '00000000-0000-4000-8000-000000000001';
const userId = '00000000-0000-4000-8000-000000000002';
const otherSessionId = '00000000-0000-4000-8000-000000000003';
const revisionOneId = '00000000-0000-4000-8000-000000000011';
const revisionTwoId = '00000000-0000-4000-8000-000000000012';
const otherRevisionId = '00000000-0000-4000-8000-000000000013';
const now = new Date();
const recentAt = new Date(now.getTime() - 5 * 60 * 1000);
const expiredAt = new Date(now.getTime() - 61 * 60 * 1000);
const retainedTrackId = '00000000-0000-4000-8000-000000000021';
const servedTrackId = '00000000-0000-4000-8000-000000000022';
const oldTrackId = '00000000-0000-4000-8000-000000000023';
// This mirrors the three tables involved in the query. The unserved
// retained row exists in both immutable revisions, but has no ledger
// event and therefore must not consume a surprise budget.
const versions = [
{ id: revisionOneId, sessionId },
{ id: revisionTwoId, sessionId },
{ id: otherRevisionId, sessionId: otherSessionId },
];
const items = [
{ planVersionId: revisionOneId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' },
{ planVersionId: revisionTwoId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' },
{ planVersionId: revisionTwoId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' },
{ planVersionId: revisionTwoId, ordinal: 8, trackId: oldTrackId, slotRole: 'surprise' },
{ planVersionId: revisionTwoId, ordinal: 9, trackId: '00000000-0000-4000-8000-000000000024', slotRole: 'favorite' },
{ planVersionId: otherRevisionId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' },
];
const events = [
// The exact revision-two association counts once.
{ id: '00000000-0000-4000-8000-000000000031', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt },
// A valid historical exposure remains in the session total but falls
// out of the rolling 60-minute counter.
{ id: '00000000-0000-4000-8000-000000000032', sessionId, userId, type: 'track_served', trackId: oldTrackId, payload: { planVersionId: revisionTwoId, ordinal: 8 }, occurredAt: expiredAt },
{ id: '00000000-0000-4000-8000-000000000033', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionOneId, ordinal: 7 }, occurredAt: recentAt }, // wrong ordinal
{ id: '00000000-0000-4000-8000-000000000034', sessionId, userId, type: 'track_served', trackId: retainedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt }, // wrong track
{ id: '00000000-0000-4000-8000-000000000035', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: '00000000-0000-4000-8000-000000000014', ordinal: 7 }, occurredAt: recentAt }, // wrong version
{ id: '00000000-0000-4000-8000-000000000036', sessionId: otherSessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: otherRevisionId, ordinal: 7 }, occurredAt: recentAt }, // wrong session
{ id: '00000000-0000-4000-8000-000000000037', sessionId, userId, type: 'track_finished', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt },
// Legacy/corrupt payloads must neither cast-fail nor claim an actual
// surprise exposure when somebody writes directly to the event ledger.
{ id: '00000000-0000-4000-8000-000000000038', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: 'not-a-uuid', ordinal: 7 }, occurredAt: recentAt },
{ id: '00000000-0000-4000-8000-000000000039', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 'not-an-integer' }, occurredAt: recentAt },
{ id: '00000000-0000-4000-8000-000000000040', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: '999999999999999999999999999999999999' }, occurredAt: recentAt },
];
const exposureIds: string[] = [];
const db = makeMockDb();
(db.pgClient.query as any).mockImplementation((sql: string, params: unknown[]) => {
// Faithfully evaluate the query's joins against the in-memory rows;
// do not treat merely planned items as delivered exposure.
expect(params).toEqual([sessionId, userId]);
const matching = events.filter(event => {
const item = items.find(candidate => candidate.planVersionId === event.payload.planVersionId
&& candidate.ordinal === event.payload.ordinal
&& candidate.trackId === event.trackId);
const version = item && versions.find(candidate => candidate.id === item.planVersionId);
return event.sessionId === sessionId
&& event.userId === userId
&& event.type === 'track_served'
&& item?.slotRole === 'surprise'
&& version?.sessionId === event.sessionId;
});
exposureIds.push(...new Set(matching.map(event => event.id)));
return Promise.resolve({
rows: [{
session_count: new Set(matching.map(event => event.id)).size,
hour_count: new Set(matching.filter(event => event.occurredAt > new Date(Date.now() - 60 * 60 * 1000)).map(event => event.id)).size,
}],
});
});
const usage = await (new SessionDirector(db) as any).getSessionSurpriseUsage(sessionId, userId);
expect(usage).toEqual({ session: 2, hour: 1 });
expect(exposureIds).toEqual([
'00000000-0000-4000-8000-000000000031',
'00000000-0000-4000-8000-000000000032',
]);
const [sql, params] = (db.pgClient.query as any).mock.calls[0] as [string, unknown[]];
expect(params).toEqual([sessionId, userId]);
expect(sql).toContain('SELECT DISTINCT e.id, e.occurred_at');
expect(sql).toContain("e.payload->>'planVersionId' ~*");
expect(sql).toContain("THEN (e.payload->>'planVersionId')::uuid");
expect(sql).toContain("e.payload->>'ordinal') ~ '^(0|[1-9][0-9]{0,8})$'");
expect(sql).toContain("THEN (e.payload->>'ordinal')::integer");
expect(sql).toContain('AND item.track_id = e.track_id');
expect(sql).toContain('AND version.session_id = e.session_id');
expect(sql).toContain("e.type = 'track_served'");
expect(sql).toContain("item.slot_role = 'surprise'");
expect(sql).toContain('WHERE e.session_id = $1');
expect(sql).toContain('AND e.user_id = $2');
expect(sql).toContain("occurred_at > NOW() - INTERVAL '1 hour'");
});
});
describe('transition-aware sequence scoring', () => {
it('prefers a smooth, on-arc candidate and treats missing analysis as lower confidence', () => {
const slot = {
position: 0,
role: 'high',
targets: { energy: { min: 0.7, max: 0.9, maxDelta: 0.2 }, tempo: { min: 120, max: 160, maxDelta: 25 } },
};
const previous = { energy: 0.72, bpm: 132 };
const smooth = scoreArcTransition(candidate('smooth'), { energy: 0.78, bpm: 140 }, previous, slot);
const abrupt = scoreArcTransition(candidate('abrupt'), { energy: 0.15, bpm: 72 }, previous, slot);
const unknown = scoreArcTransition(candidate('unknown'), {}, previous, slot);
expect(smooth.score).toBeGreaterThan(abrupt.score);
expect(unknown.confidence).toBeLessThan(smooth.confidence);
expect(unknown.score).toBeGreaterThan(0);
});
it('selects a callback inside its separation window while preserving hard caps', () => {
const candidates = ['anchor', 'bridge-a', 'bridge-b', 'return', 'other'].map(id => ({ ...candidate(id), generatorId: 'comfort' }));
const metadata = new Map([
['anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }],
['bridge-a', { artistId: 'a2', albumId: 'a2', energy: 0.5 }],
['bridge-b', { artistId: 'a3', albumId: 'a3', energy: 0.5 }],
['return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.5 }],
['other', { artistId: 'a4', albumId: 'a5', favorite: false, energy: 0.5 }],
]);
const token = { id: 'theme', theme: 'artist' as const, minSeparation: 2, maxSeparation: 4 };
const result = selectConstrainedSequence({
candidates,
metadata,
budgets: [],
roleToGeneratorIds: () => ['comfort'],
slots: [
{ position: 0, role: 'known', targets: {}, callback: { ...token, phase: 'anchor' as const } },
{ position: 1, role: 'known', targets: {} },
{ position: 2, role: 'known', targets: {} },
{ position: 3, role: 'favorite', targets: {}, callback: { ...token, phase: 'return' as const } },
],
});
expect(result.plan.map(item => item.trackId)).toEqual(['anchor', 'bridge-a', 'bridge-b', 'return']);
expect(result.plan[3].plan?.scoreBreakdown.callback).toBe(1);
});
it('plans an energetic rise through peak and cooldown when measured candidates exist', () => {
const db = makeMockDb();
const director = new SessionDirector(db);
const slots = director.getArcSlots('energetic', 8);
const entries = [
['medium-1', 'comfort', 0.55, 110], ['medium-2', 'comfort', 0.62, 125],
['high-1', 'discovery', 0.72, 135], ['high-2', 'discovery', 0.8, 150],
['peak', 'deep-dive', 0.9, 165], ['surprise', 'discovery', 0.85, 155],
['cooldown-1', 'comfort', 0.62, 130], ['cooldown-2', 'comfort', 0.5, 110],
] as const;
const candidates = entries.map(([trackId, generatorId]) => ({ ...candidate(trackId), generatorId }));
const metadata: Map<string, any> = new Map(entries.map(([trackId, generatorId, energy, bpm], index) => [trackId, {
artistId: `artist-${index}`, albumId: `album-${index}`, energy, bpm,
valence: 0.6, acousticness: trackId.startsWith('cooldown') ? 0.3 : 0.1,
favorite: String(trackId).startsWith('medium') || String(trackId).startsWith('cooldown'),
newArtist: generatorId === 'discovery',
}]));
const result = selectConstrainedSequence({
candidates, slots, metadata, budgets: [],
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
});
expect(result.plan.map(item => item.trackId)).toEqual(entries.map(([trackId]) => trackId));
expect(result.plan.map(item => metadata.get(item.trackId)?.energy))
.toEqual([0.55, 0.62, 0.72, 0.8, 0.9, 0.85, 0.62, 0.5]);
expect(result.relaxations).toEqual([]);
});
it('keeps the familiar-new-familiar discovery callback intact', () => {
const db = makeMockDb();
const director = new SessionDirector(db);
const candidates = [
{ ...candidate('favorite-anchor'), generatorId: 'deep-dive' },
{ ...candidate('adjacent'), generatorId: 'adjacent' },
{ ...candidate('new'), generatorId: 'discovery' },
{ ...candidate('favorite-return'), generatorId: 'deep-dive' },
];
const metadata = new Map([
['favorite-anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }],
['adjacent', { artistId: 'bridge', albumId: 'a2', energy: 0.55 }],
['new', { artistId: 'new', albumId: 'a3', newArtist: true, energy: 0.6 }],
['favorite-return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.55 }],
]);
const result = selectConstrainedSequence({
candidates, slots: director.getArcSlots('discovery', 4), metadata, budgets: [],
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
});
expect(result.plan.map(item => item.trackId))
.toEqual(['favorite-anchor', 'adjacent', 'new', 'favorite-return']);
expect(result.plan[3].plan?.explanation.callback).toMatchObject({ phase: 'return', matchScore: 1 });
});
it('downgrades a surprise deterministically when no preferred recovery anchor is feasible', () => {
const db = makeMockDb();
const director = new SessionDirector(db);
const candidates = [
{ ...candidate('favorite-anchor'), generatorId: 'deep-dive' },
{ ...candidate('adjacent'), generatorId: 'adjacent' },
{ ...candidate('unrecoverable-surprise'), generatorId: 'discovery' },
// This may fill the downgraded favourite slot only through the
// explicitly persisted arc-source relaxation; it cannot reserve a
// recovery for the surprise because it is not a favourite source.
{ ...candidate('fallback'), generatorId: 'contextual' },
];
const metadata = new Map(candidates.map((item, index) => [item.trackId, {
artistId: `artist-${index}`, albumId: `album-${index}`,
favorite: item.trackId === 'favorite-anchor', newArtist: item.trackId === 'unrecoverable-surprise', energy: 0.5,
}]));
const result = selectConstrainedSequence({
candidates, slots: director.getArcSlots('discovery', 3), metadata, budgets: [],
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
});
expect(result.plan.map(item => item.trackId)).toEqual(['favorite-anchor', 'adjacent', 'fallback']);
expect(result.plan[2].plan?.slotRole).toBe('favorite');
expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'surprise_recovery' }));
});
it('falls back safely with sparse audio analysis and records arc precision', () => {
const db = makeMockDb();
const director = new SessionDirector(db);
const result = selectConstrainedSequence({
candidates: [
{ ...candidate('unknown-analysis'), generatorId: 'comfort' },
{ ...candidate('wrong-energy'), generatorId: 'comfort' },
],
slots: director.getArcSlots('energetic', 1),
metadata: new Map([
['unknown-analysis', { artistId: 'a1', albumId: 'x1' }],
['wrong-energy', { artistId: 'a2', albumId: 'x2', energy: 0.1, bpm: 70 }],
]),
budgets: [], roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
});
expect(result.plan.map(item => item.trackId)).toEqual(['unknown-analysis']);
expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'arc_precision' }));
expect(result.plan[0].plan?.explanation.arcPrecision).toMatchObject({ measuredFit: false });
});
});
describe('computeEntropy', () => {
@@ -91,7 +414,7 @@ describe('SessionDirector', () => {
{ trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] },
{ trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] },
];
const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }];
const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
@@ -99,6 +422,478 @@ describe('SessionDirector', () => {
const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, repetitionState);
expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance);
});
it('lifts a never-played track above a familiar one of similar relevance', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({
rows: [
{ track_id: 'heard', artist_id: 'a1', unheard: false, favorite: false },
{ track_id: 'unheard', artist_id: 'a2', unheard: true, favorite: false },
],
});
const director = new SessionDirector(db);
const candidates = [
{ trackId: 'heard', generatorId: 'comfort', relevance: 0.6, explanation: [] },
{ trackId: 'unheard', generatorId: 'library-fallback', relevance: 0.4, explanation: [] },
] as any;
const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
const repetitionState = { recentTrackIds: new Set<string>(), recentArtistIds: new Set<string>() };
const ranked = await director.rankCandidates(candidates, fatigue, [], state, repetitionState);
expect(ranked[0].trackId).toBe('unheard');
});
});
describe('unheard floor', () => {
const director = new SessionDirector(makeMockDb());
const meta = (unheard: boolean) => ({ unheard });
it('substitutes unheard candidates for the lowest-priority heard entries', () => {
const plan = ['p0', 'p1', 'p2', 'p3'].map(candidate);
const pool = [...plan, ...['u1', 'u2'].map(candidate)];
const metadata = new Map([
...plan.map(item => [item.trackId, meta(false)] as const),
['u1', meta(true)] as const,
['u2', meta(true)] as const,
]);
const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []);
// One in four of a four-item plan, taken from the tail.
expect(filled.map((item: any) => item.trackId)).toEqual(['p0', 'p1', 'p2', 'u1']);
});
it('never displaces the track about to play', () => {
const plan = [candidate('p0')];
const pool = [candidate('p0'), candidate('u1')];
const metadata = new Map([['p0', meta(false)] as const, ['u1', meta(true)] as const]);
const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []);
expect(filled.map((item: any) => item.trackId)).toEqual(['p0']);
});
it('leaves a plan that already meets the floor alone', () => {
const plan = [candidate('u1'), candidate('p1'), candidate('p2'), candidate('p3')];
const pool = [...plan, candidate('u2')];
const metadata = new Map([
['u1', meta(true)] as const,
['p1', meta(false)] as const,
['p2', meta(false)] as const,
['p3', meta(false)] as const,
['u2', meta(true)] as const,
]);
const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, []);
expect(filled.map((item: any) => item.trackId)).toEqual(['u1', 'p1', 'p2', 'p3']);
});
it('counts the retained tail towards the floor', () => {
const plan = [candidate('p0'), candidate('p1')];
const retained = [candidate('u0'), candidate('r1')];
const pool = [...plan, candidate('u1')];
const metadata = new Map([
['p0', meta(false)] as const,
['p1', meta(false)] as const,
['u0', meta(true)] as const,
['r1', meta(false)] as const,
['u1', meta(true)] as const,
]);
const filled = (director as any).enforceUnheardFloor(plan, pool, metadata, retained);
expect(filled.map((item: any) => item.trackId)).toEqual(['p0', 'p1']);
});
});
describe('recent session fingerprint penalty', () => {
it('softly penalizes a repeated session shape without excluding it', () => {
const repeated = sessionSimilarityPenalty(
{ artistId: 'artist-1', genreId: 'genre-1' },
'comfort',
[{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }],
);
const newShape = sessionSimilarityPenalty(
{ artistId: 'artist-2', genreId: 'genre-2' },
'discovery',
[{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }],
);
expect(repeated).toBeGreaterThan(0);
expect(repeated).toBeLessThanOrEqual(0.18);
expect(newShape).toBe(0);
});
});
describe('sequence constraints', () => {
const slots = Array.from({ length: 10 }, (_, position) => ({ position, role: 'known' }));
const budgets = [
{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 },
{ dimension: 'genre', budgetShare: 0.4, horizonMin: 30, spent: 0 },
{ dimension: 'language', budgetShare: 0.6, horizonMin: 30, spent: 0 },
{ dimension: 'instrumental', budgetShare: 0.1, horizonMin: 30, spent: 0 },
{ dimension: 'new_artist', budgetShare: 0.15, horizonMin: 60, spent: 0 },
{ dimension: 'favorite', budgetShare: 0.25, horizonMin: 60, spent: 0 },
];
const roleToGeneratorIds = () => ['comfort'];
it('projects budgets while enforcing artist and album caps across the sequence', () => {
const candidates = Array.from({ length: 15 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' }));
const metadata = new Map(candidates.map((item, i) => [item.trackId, {
artistId: i < 5 ? 'overplayed-artist' : `artist-${i}`,
albumId: i < 4 ? 'overplayed-album' : `album-${i}`,
genreId: i < 6 ? 'genre-a' : 'genre-b',
language: i < 7 ? 'ja' : 'en',
instrumental: i === 7,
newArtist: i === 8 || i === 9,
favorite: i === 10 || i === 11 || i === 12,
}]));
const result = selectConstrainedSequence({ candidates, slots, metadata, budgets, roleToGeneratorIds });
expect(result.plan).toHaveLength(10);
const ids = result.plan.map(item => item.trackId);
expect(ids.filter(id => metadata.get(id)?.artistId === 'overplayed-artist')).toHaveLength(2);
expect(ids.filter(id => metadata.get(id)?.albumId === 'overplayed-album').length).toBeLessThanOrEqual(3);
expect(ids.filter(id => metadata.get(id)?.instrumental)).toHaveLength(1);
expect(ids.filter(id => metadata.get(id)?.newArtist)).toHaveLength(2);
expect(ids.filter(id => metadata.get(id)?.favorite)).toHaveLength(3);
});
it('corrects the detected dimension directly before relaxing it', () => {
const candidates = ['ja-1', 'ja-2', 'en-1', 'en-2'].map(trackId => ({ ...candidate(trackId), generatorId: 'comfort' }));
const metadata = new Map([
['ja-1', { artistId: 'a1', albumId: 'x1', language: 'ja' }],
['ja-2', { artistId: 'a2', albumId: 'x2', language: 'ja' }],
['en-1', { artistId: 'a3', albumId: 'x3', language: 'en' }],
['en-2', { artistId: 'a4', albumId: 'x4', language: 'en' }],
]);
const result = selectConstrainedSequence({
candidates,
slots: slots.slice(0, 2),
metadata,
budgets: [],
roleToGeneratorIds,
loopDimension: 'language',
loopedValue: 'ja',
});
expect(result.plan.map(item => item.trackId)).toEqual(['en-1', 'en-2']);
expect(result.relaxations).toEqual([]);
});
it('records a structured soft relaxation without violating hard album caps', () => {
const candidates = Array.from({ length: 5 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' }));
const metadata = new Map(candidates.map((item, i) => [item.trackId, {
artistId: `artist-${i}`,
albumId: i < 4 ? 'single-album' : `album-${i}`,
language: 'ja',
}]));
const result = selectConstrainedSequence({
candidates, slots: slots.slice(0, 5), metadata, budgets: [], roleToGeneratorIds,
loopDimension: 'language', loopedValue: 'ja',
});
expect(result.plan.filter(item => metadata.get(item.trackId)?.albumId === 'single-album')).toHaveLength(3);
expect(result.relaxations).toContainEqual(expect.objectContaining({ stage: 'soft_budget' }));
});
it('counts the retained queue tail against hard artist caps before selecting replacements', () => {
const retained = [candidate('queued-a1'), candidate('queued-a2')];
const candidates = [candidate('same-artist'), candidate('other-artist')].map(item => ({ ...item, generatorId: 'comfort' }));
const metadata = new Map([
['queued-a1', { artistId: 'artist-a', albumId: 'queued-album-1' }],
['queued-a2', { artistId: 'artist-a', albumId: 'queued-album-2' }],
['same-artist', { artistId: 'artist-a', albumId: 'replacement-album' }],
['other-artist', { artistId: 'artist-b', albumId: 'replacement-album-2' }],
]);
const result = selectConstrainedSequence({
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, retainedPlan: retained,
});
expect(result.plan.map(item => item.trackId)).toEqual(['other-artist']);
});
it('enforces the three-track album limit across the rolling 40-play history', () => {
const candidates = [candidate('same-album'), candidate('new-album')].map(item => ({ ...item, generatorId: 'comfort' }));
const metadata = new Map([
['same-album', { artistId: 'a4', albumId: 'album-a' }],
['new-album', { artistId: 'a5', albumId: 'album-b' }],
]);
const albumHistory = Array.from({ length: 40 }, (_, index) => ({
artistId: `history-${index}`,
albumId: index < 3 ? 'album-a' : `history-album-${index}`,
}));
const result = selectConstrainedSequence({
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, albumHistory,
});
expect(result.plan.map(item => item.trackId)).toEqual(['new-album']);
});
it('projects budgets over historical counts and the planned horizon with track-consistent denominators', () => {
const candidates = [candidate('ja'), candidate('en')].map(item => ({ ...item, generatorId: 'comfort' }));
const metadata = new Map([
['ja', { artistId: 'a1', albumId: 'x1', genreId: 'j-pop' }],
['en', { artistId: 'a2', albumId: 'x2', genreId: 'rock' }],
]);
const historicalValues = new Map([['j-pop', 4], ['rock', 1]]);
const result = selectConstrainedSequence({
candidates,
slots: slots.slice(0, 1),
metadata,
budgets: [{ dimension: 'genre', budgetShare: 0.6, horizonMin: 30, spent: 0.8, historicalTotal: 5, historicalValues }],
roleToGeneratorIds,
});
// 4 / 5 becomes 4 / 6 if rock is selected; a fifth j-pop track would
// exceed the 60% cap. The selector must use history + proposal, not only
// the one-track replacement queue.
expect(result.plan.map(item => item.trackId)).toEqual(['en']);
});
it('does not treat unknown instrumentation as a vocal/instrumental budget credit', () => {
const candidates = [candidate('unknown'), candidate('instrumental')].map(item => ({ ...item, generatorId: 'comfort' }));
const metadata = new Map([
['unknown', { artistId: 'a1', albumId: 'x1' }],
['instrumental', { artistId: 'a2', albumId: 'x2', instrumental: true }],
]);
const result = selectConstrainedSequence({
candidates,
slots: slots.slice(0, 1),
metadata,
budgets: [{ dimension: 'instrumental', budgetShare: 1, horizonMin: 30, spent: 0, historicalTotal: 0, historicalValues: new Map() }],
roleToGeneratorIds,
});
expect(result.plan.map(item => item.trackId)).toEqual(['instrumental']);
});
it('excludes candidates matching any detected producer or label, not only their first claim', () => {
const candidates = [candidate('producer-match'), candidate('label-match'), candidate('safe')].map(item => ({ ...item, generatorId: 'comfort' }));
const metadata = new Map([
['producer-match', { artistId: 'a1', albumId: 'x1', producerIds: ['other', 'producer-loop'] }],
['label-match', { artistId: 'a2', albumId: 'x2', labelIds: ['other', 'label-loop'] }],
['safe', { artistId: 'a3', albumId: 'x3', producerIds: ['safe-producer'], labelIds: ['safe-label'] }],
]);
const producerResult = selectConstrainedSequence({
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds,
loopDimension: 'producer', loopedValues: ['producer-loop'],
});
const labelResult = selectConstrainedSequence({
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds,
loopDimension: 'label', loopedValues: ['label-loop'],
});
expect(producerResult.plan.map(item => item.trackId)).not.toContain('producer-match');
expect(labelResult.plan.map(item => item.trackId)).not.toContain('label-match');
});
it('keeps stable candidate order while reusing a role preference pool', () => {
const candidates = [
{ ...candidate('comfort-first'), generatorId: 'comfort' },
{ ...candidate('adjacent-first'), generatorId: 'adjacent' },
{ ...candidate('comfort-second'), generatorId: 'comfort' },
{ ...candidate('adjacent-second'), generatorId: 'adjacent' },
];
const metadata = new Map(candidates.map((item, index) => [item.trackId, {
artistId: `artist-${index}`, albumId: `album-${index}`,
}]));
const result = selectConstrainedSequence({
candidates,
slots: Array.from({ length: 4 }, (_, position) => ({ position, role: position % 2 ? 'adjacent' : 'known' })),
metadata,
budgets: [],
roleToGeneratorIds: role => role === 'adjacent' ? ['adjacent'] : ['comfort'],
});
expect(result.plan.map(item => item.trackId)).toEqual([
'comfort-first', 'adjacent-first', 'comfort-second', 'adjacent-second',
]);
});
});
describe('anti-loop signals', () => {
const variedRecentPlays = Array.from({ length: 4 }, (_, index) => ({
trackId: `00000000-0000-0000-0000-00000000000${index + 1}`,
artistId: `artist-${index}`,
genreId: `genre-${index}`,
language: `lang-${index}`,
bpm: 80 + index * 30,
energy: index / 3,
vocal: null,
decade: 1980 + index * 10,
valence: index % 2,
albumId: `album-${index}`,
producerIds: [],
labelIds: [],
}));
it('returns fused producer lineage from resolved main artists', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({ rows: [{ lineage_id: 'producer-a' }, { lineage_id: 'producer-b' }] });
const director = new SessionDirector(db);
const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays);
expect(signal).toEqual({ dimension: 'producer', values: ['producer-a', 'producer-b'] });
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
expect(sql).toContain('claim_fusion cf');
expect(sql).toContain("cf.subject_type = 'artist'");
expect(sql).toContain('cf.object_id = recent.artist_id');
expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id');
});
it('returns label identities after a producer check finds no loop', async () => {
const db = makeMockDb();
(db.pgClient.query as any)
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ lineage_id: 'label-a' }] });
const director = new SessionDirector(db);
const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays);
expect(signal).toEqual({ dimension: 'label', values: ['label-a'] });
const sql = (db.pgClient.query as any).mock.calls[1][0] as string;
expect(sql).toContain("cf.predicate = 'same_label_as'");
expect(sql).toContain('cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id');
});
});
describe('planner metadata and integration boundaries', () => {
it('counts every completed play in a budget horizon while only classifying known values', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({
rows: [{ value: 'rock', cnt: 3 }, { value: null, cnt: 2 }],
});
const director = new SessionDirector(db);
const usage = await (director as any).loadBudgetUsage('user-1', 'genre', 30);
expect(usage).toMatchObject({ total: 5, spent: 0.6 });
expect(usage.values).toEqual(new Map([['rock', 3]]));
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
expect(sql).toContain('WITH completed_plays AS');
expect(sql).not.toContain('WHERE value IS NOT NULL');
});
it('loads producer and label lineage from fused relationships of the resolved main artist', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockResolvedValue({
rows: [{
track_id: 'track-1', artist_id: 'artist-1', album_id: 'album-1', genre_id: null,
language: null, instrumentalness: null, favorite: false, new_artist: false,
energy: null, bpm: null, valence: null, release_date: null,
producer_ids: ['producer-from-object', 'producer-from-subject'],
label_ids: ['label-from-object', 'label-from-subject'],
}],
});
const director = new SessionDirector(db);
const metadata = await (director as any).loadConstraintMetadata('user-1', ['track-1']);
expect(metadata.get('track-1')).toMatchObject({
artistId: 'artist-1',
producerIds: ['producer-from-object', 'producer-from-subject'],
labelIds: ['label-from-object', 'label-from-subject'],
});
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
expect(sql).toContain('FROM claim_fusion cf');
expect(sql).toContain("cf.predicate = 'produced'");
expect(sql).toContain("cf.predicate = 'same_label_as'");
expect(sql).toContain('cf.subject_id = artist.artist_id OR cf.object_id = artist.artist_id');
expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id');
});
it('carries the retained tail and all 40 album-history plays through replan into constraint selection', async () => {
const db = makeMockDb({
getVibeSessionTrackIds: vi.fn().mockResolvedValue([]),
getListenerBeliefs: vi.fn().mockResolvedValue([]),
});
const director = new SessionDirector(db);
const history = Array.from({ length: 40 }, (_, index) => ({
track_id: `history-${index}`, album_id: index < 3 ? 'history-album' : `old-album-${index}`,
artist_id: `history-artist-${index}`, genre_id: null, bpm: null, energy: null,
valence: null, instrumentalness: null, language: null, release_date: null,
producer_ids: [], label_ids: [],
}));
(db.pgClient.query as any).mockImplementation((sql: string, params: unknown[] = []) => {
if (sql.includes('FROM play_history ph') && sql.includes('LIMIT $2')) return Promise.resolve({ rows: history });
if (sql.includes('WHERE t.id = ANY($2::uuid[])')) {
const ids = params[1] as string[];
return Promise.resolve({ rows: ids.map(trackId => ({
track_id: trackId,
artist_id: `artist-${trackId}`,
album_id: trackId === 'history-album-candidate' ? 'history-album' : `album-${trackId}`,
genre_id: null, language: null, instrumentalness: null, favorite: false,
new_artist: false, energy: null, bpm: null, valence: null, release_date: null,
producer_ids: trackId === 'producer-loop-candidate' ? ['producer-loop'] : [],
label_ids: [],
})) });
}
return Promise.resolve({ rows: [] });
});
vi.spyOn(director, 'buildState').mockResolvedValue({
energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 0, lastArtistIds: [], lastGenreIds: [], context: null,
});
vi.spyOn(director, 'computeFatigue').mockResolvedValue({
artist: new Map(), album: new Map(), genre: new Map(), language: new Map(), track: new Map(), vocal: 0.5,
});
vi.spyOn(director, 'getBudgets').mockResolvedValue([]);
vi.spyOn(director, 'buildRepetitionState').mockResolvedValue({ recentTrackIds: new Set(), recentArtistIds: new Set() });
vi.spyOn(director, 'rankCandidates').mockImplementation(async candidates => candidates);
vi.spyOn(director, 'detectAntiLoop').mockResolvedValue({ dimension: 'producer', values: ['producer-loop'] });
vi.spyOn(director as any, 'loadArtistMap').mockResolvedValue(new Map());
const originalGenerators = [...ALL_GENERATORS];
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async () => [
{ ...candidate('producer-loop-candidate'), generatorId: 'comfort' },
{ ...candidate('history-album-candidate'), generatorId: 'comfort' },
...Array.from({ length: 5 }, (_, index) => ({ ...candidate(`safe-candidate-comfort-${index}`), generatorId: 'comfort' })),
...Array.from({ length: 4 }, (_, index) => ({ ...candidate(`safe-candidate-adjacent-${index}`), generatorId: 'adjacent' })),
...Array.from({ length: 2 }, (_, index) => ({ ...candidate(`safe-candidate-favorite-${index}`), generatorId: 'deep-dive' })),
]);
let captured: any;
vi.spyOn(director as any, 'constrainedSequence').mockImplementation((params: any) => {
captured = params;
return selectConstrainedSequence(params);
});
try {
const retained = Array.from({ length: 9 }, (_, index) => ({ ...candidate(`queued-${index}`), generatorId: 'comfort' }));
const plan = await director.replan('user-1', 'session-1', retained, []);
expect(captured.retainedPlan.map((item: { trackId: string }) => item.trackId)).toEqual(retained.map(item => item.trackId));
expect(captured.albumHistory).toHaveLength(40);
expect(captured.loopDimension).toBe('producer');
expect(captured.metadata.get('producer-loop-candidate').producerIds).toEqual(['producer-loop']);
expect(plan.map(item => item.trackId)).not.toContain('history-album-candidate');
expect(plan.map(item => item.trackId)).toContain('safe-candidate-comfort-0');
} finally {
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, ...originalGenerators);
}
});
});
describe('heard cooldown', () => {
it('hands generators every track heard inside the window as a hard exclusion', async () => {
const db = makeMockDb();
(db.pgClient.query as any).mockImplementation((sql: string) => {
if (sql.includes('heard_rows')) return Promise.resolve({ rows: [{ track_id: 'heard-last-week' }] });
return Promise.resolve({ rows: [] });
});
const director = new SessionDirector(db);
vi.spyOn(director, 'buildState').mockResolvedValue({
energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 0, lastArtistIds: [], lastGenreIds: [], context: null,
});
vi.spyOn(director, 'computeFatigue').mockResolvedValue({
artist: new Map(), album: new Map(), genre: new Map(), language: new Map(), track: new Map(), vocal: 0.5,
});
vi.spyOn(director, 'getBudgets').mockResolvedValue([]);
vi.spyOn(director, 'buildRepetitionState').mockResolvedValue({ recentTrackIds: new Set(), recentArtistIds: new Set() });
vi.spyOn(director as any, 'loadArtistMap').mockResolvedValue(new Map());
(db as any).getVibeSessionTrackIds = vi.fn().mockResolvedValue([]);
const originalGenerators = [...ALL_GENERATORS];
let seenExclusions: string[] = [];
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async (_db, ctx) => {
seenExclusions = [...ctx.recentExclusions];
return [{ ...candidate('heard-last-week'), generatorId: 'comfort' }];
});
try {
const plan = await director.buildPlan('user-1', 'session-1');
expect(seenExclusions).toContain('heard-last-week');
expect(plan.map(item => item.trackId)).not.toContain('heard-last-week');
} finally {
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, ...originalGenerators);
}
});
});
describe('buildState', () => {
@@ -0,0 +1,319 @@
import { describe, expect, it, vi } from 'vitest';
import { DbService } from './db.service.js';
import {
DEFAULT_VIBE_POLICY_VERSION,
VibeSessionCoordinator,
VibeSessionLifecycleError,
VibePlanNotFoundError,
} from './vibe-session-coordinator.service.js';
const SESSION_ID = '11111111-1111-4111-8111-111111111111';
const TRACK_ID = '22222222-2222-4222-8222-222222222222';
const EVENT_ID = '33333333-3333-4333-8333-333333333333';
function session(status: 'active' | 'ended' = 'active') {
return {
id: SESSION_ID, user_id: 'user-1', status, seed_track_id: null,
context: {}, policy_version: DEFAULT_VIBE_POLICY_VERSION,
started_at: new Date('2026-01-01T00:00:00.000Z'),
last_event_at: new Date('2026-01-01T00:00:00.000Z'), ended_at: status === 'ended' ? new Date() : null,
} as any;
}
function plan() {
return {
id: 'plan-1', session_id: SESSION_ID, version: 1, reason: 'session_started',
state_snapshot: { energy: 0.5 }, objective_snapshot: {}, created_at: new Date(),
items: [{
plan_version_id: 'plan-1', ordinal: 0, track_id: TRACK_ID, slot_role: 'next',
candidate_source: 'comfort', score: 0.8, score_breakdown: { relevance: 0.8 },
explanation: [], committed: false,
}],
} as any;
}
function setup() {
const db = {
createVibeSession: vi.fn().mockResolvedValue(session()),
createSessionState: vi.fn().mockResolvedValue(SESSION_ID),
recordVibeEvent: vi.fn().mockResolvedValue({ event: { id: 'event-1' }, inserted: true }),
persistVibePlan: vi.fn().mockResolvedValue(plan()),
publishVibePlan: vi.fn().mockImplementation((input: { version?: number; reason: string }) => Promise.resolve({
...plan(), version: input.version ?? 2, reason: input.reason,
})),
getVibeSession: vi.fn().mockResolvedValue(session()),
getVibePlan: vi.fn().mockResolvedValue(plan()),
endVibeSession: vi.fn().mockResolvedValue(session('ended')),
endVibeSessionWithEvent: vi.fn().mockResolvedValue({ session: session('ended'), ended: true }),
resumeVibeSession: vi.fn().mockResolvedValue({ session: session(), resumed: true }),
serveNextVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }),
advancePastUnplayableVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }),
persistNextVibePlan: vi.fn().mockResolvedValue({ ...plan(), version: 2, reason: 'feedback:completed' }),
getVibePlanForFeedbackEvent: vi.fn().mockResolvedValue(null),
dislikeTrack: vi.fn().mockResolvedValue(undefined),
} as unknown as DbService;
const director = {
buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
buildState: vi.fn().mockResolvedValue({ energy: 0.5, noveltyHunger: 0.3 }),
replan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
} as any;
return { db, director, coordinator: new VibeSessionCoordinator(db, director) };
}
describe('VibeSessionCoordinator', () => {
it('creates an authoritative session, shadow state, initial plan revision, and ledger events', async () => {
const { db, director, coordinator } = setup();
const response = await coordinator.start('user-1', {});
expect(response).toMatchObject({ sessionId: SESSION_ID, planVersion: 1, now: { track_id: TRACK_ID } });
expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1', policyVersion: DEFAULT_VIBE_POLICY_VERSION,
}));
expect(db.createSessionState).toHaveBeenCalledWith('user-1', undefined, expect.any(Object), SESSION_ID);
expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, undefined);
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
sessionId: SESSION_ID, version: 1, reason: 'session_started',
items: [expect.objectContaining({ track_id: TRACK_ID, committed: false })],
}));
expect((db.recordVibeEvent as any).mock.calls.map(([input]: any[]) => input.type))
.toEqual(['session_started']);
});
it('persists a director-selected arc role and explainable sequence score', async () => {
const { db, director, coordinator } = setup();
(director.buildPlan as any).mockResolvedValueOnce([{
trackId: TRACK_ID, generatorId: 'discovery', relevance: 0.7, explanation: [{ predicate: 'near' }],
plan: {
slotRole: 'surprise', score: 0.82,
scoreBreakdown: { relevance: 0.7, transition: 0.9, arcTarget: 0.85 },
explanation: { arcRole: 'surprise', surprise: { recoveryRole: 'favorite' } },
},
}]);
await coordinator.start('user-1', {});
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
items: [expect.objectContaining({
slot_role: 'surprise', score: 0.82,
score_breakdown: expect.objectContaining({ transition: 0.9 }),
explanation: expect.objectContaining({
paths: [{ predicate: 'near' }],
planner: expect.objectContaining({ arcRole: 'surprise' }),
}),
})],
}));
});
it('returns the canonical replacement on an idempotent material-event retry without replanning', async () => {
const { db, coordinator } = setup();
(db.recordVibeEvent as any).mockResolvedValueOnce({
event: { id: 'event-1', client_event_id: EVENT_ID, type: 'skipped' }, inserted: false,
});
(db.getVibePlanForFeedbackEvent as any).mockResolvedValueOnce({ ...plan(), version: 2 });
const response = await coordinator.appendEvent('user-1', SESSION_ID, {
eventId: EVENT_ID, type: 'skipped', trackId: TRACK_ID,
});
expect(response).toMatchObject({ idempotent: true, planVersion: 2, replanned: false, replanReason: null });
expect(db.recordVibeEvent).toHaveBeenCalledWith(expect.objectContaining({ clientEventId: EVENT_ID }));
expect(db.persistNextVibePlan).not.toHaveBeenCalled();
});
it('records a durable dislike so the track is gone from later sessions too', async () => {
const { db, coordinator } = setup();
await coordinator.appendEvent('user-1', SESSION_ID, {
eventId: EVENT_ID, type: 'disliked', trackId: TRACK_ID,
});
expect(db.dislikeTrack).toHaveBeenCalledWith('user-1', TRACK_ID);
});
it('does not dislike a track twice when its event is delivered again', async () => {
const { db, coordinator } = setup();
(db.recordVibeEvent as any).mockResolvedValueOnce({
event: { id: 'event-1', client_event_id: EVENT_ID, type: 'disliked' }, inserted: false,
});
await coordinator.appendEvent('user-1', SESSION_ID, {
eventId: EVENT_ID, type: 'disliked', trackId: TRACK_ID,
});
expect(db.dislikeTrack).not.toHaveBeenCalled();
});
it('leaves the library alone for feedback that is not a dislike', async () => {
const { db, coordinator } = setup();
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'skipped', trackId: TRACK_ID });
expect(db.dislikeTrack).not.toHaveBeenCalled();
});
it('recovers a material feedback replan when its first persistence attempt failed', async () => {
const { db, coordinator } = setup();
(db.publishVibePlan as any).mockRejectedValueOnce(new Error('temporary database failure'));
await expect(coordinator.appendEvent('user-1', SESSION_ID, {
eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID,
})).rejects.toThrow('temporary database failure');
(db.recordVibeEvent as any).mockResolvedValueOnce({
event: { id: 'event-1', client_event_id: EVENT_ID, type: 'completed' }, inserted: false,
});
const recovered = await coordinator.appendEvent('user-1', SESSION_ID, {
eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID,
});
expect(db.publishVibePlan).toHaveBeenCalledTimes(2);
expect(recovered).toMatchObject({ idempotent: true, replanned: true, planVersion: 2 });
});
it('persists a replacement revision for material feedback and returns its preview', async () => {
const { db, director, coordinator } = setup();
const response = await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });
expect(director.replan).toHaveBeenCalledWith(
'user-1', SESSION_ID, expect.any(Array), [TRACK_ID], TRACK_ID,
{ excludedTrackIds: new Set() },
);
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
sessionId: SESSION_ID, reason: 'feedback:completed', items: [expect.objectContaining({ committed: false })],
}));
expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 });
});
it('creates a neutral durable profile until listening behaviour provides evidence', async () => {
const { db, coordinator } = setup();
await coordinator.start('user-1', {});
expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({
profile: expect.objectContaining({
goals: { type: 'discovery', target: 1, progress: 0 },
explorationCoefficient: 0.3,
discoveryRadius: 0.38,
}),
}));
});
it('passes the durable unserved callback tail into the retention-aware replan', async () => {
const { db, director, coordinator } = setup();
(db.getVibePlan as any).mockResolvedValue({
...plan(),
items: [{
...plan().items[0],
slot_role: 'favorite',
explanation: {
paths: [],
planner: { arcRole: 'favorite', callback: { id: 'theme:0', phase: 'return' } },
},
}],
});
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: 'other-track' });
expect(director.replan).toHaveBeenCalledWith(
'user-1', SESSION_ID,
[expect.objectContaining({
trackId: TRACK_ID,
plan: expect.objectContaining({ slotRole: 'favorite', explanation: expect.objectContaining({ arcRole: 'favorite' }) }),
})],
['other-track'], 'other-track', { excludedTrackIds: new Set() },
);
});
it('keeps the durable seed excluded when feedback supplies a different local replan anchor', async () => {
const { db, director, coordinator } = setup();
const seedTrackId = '44444444-4444-4444-8444-444444444444';
(db.getVibeSession as any).mockResolvedValue({ ...session(), seed_track_id: seedTrackId });
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });
expect(director.replan).toHaveBeenCalledWith(
'user-1',
SESSION_ID,
expect.any(Array),
[TRACK_ID],
TRACK_ID,
{ excludedTrackIds: new Set([seedTrackId]) },
);
});
it('resumes only the caller-owned session and serves a plan item through the durable API', async () => {
const { db, coordinator } = setup();
const resumed = await coordinator.start('user-1', { resumeSessionId: SESSION_ID });
const served = await coordinator.serveNext('user-1', SESSION_ID);
expect(db.resumeVibeSession).toHaveBeenCalledWith(SESSION_ID, 'user-1');
expect(resumed.sessionId).toBe(SESSION_ID);
expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1');
expect(served.now).toMatchObject({ track_id: TRACK_ID });
});
it('returns a lifecycle conflict when the ledger rejects a new terminal-session event', async () => {
const { db, coordinator } = setup();
(db.recordVibeEvent as any).mockRejectedValueOnce(new Error('Cannot record a new event for ended Vibe session'));
await expect(coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed' }))
.rejects.toBeInstanceOf(VibeSessionLifecycleError);
});
it('distinguishes a missing requested revision from an empty latest plan', async () => {
const { db, coordinator } = setup();
(db.getVibePlan as any).mockResolvedValueOnce(null);
await expect(coordinator.getPlan('user-1', SESSION_ID, 99)).rejects.toBeInstanceOf(VibePlanNotFoundError);
});
it('returns 404-worthy failure when a session has no latest plan', async () => {
const { db, coordinator } = setup();
(db.getVibePlan as any).mockResolvedValueOnce(null);
await expect(coordinator.getPlan('user-1', SESSION_ID)).rejects.toBeInstanceOf(VibePlanNotFoundError);
});
it('does not commit a stale version-aware next request and returns the current preview', async () => {
const { db, coordinator } = setup();
(db.serveNextVibePlanItem as any).mockResolvedValueOnce({ item: null, stale: true });
const result = await coordinator.serveNext('user-1', SESSION_ID, 1);
expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', 1);
expect(result).toMatchObject({ planVersion: 1, now: { track_id: TRACK_ID } });
});
it('advances past an unplayable served item using a distinct idempotent event protocol', async () => {
const { db, coordinator } = setup();
const planVersionId = '44444444-4444-4444-8444-444444444444';
const eventId = '55555555-5555-4555-8555-555555555555';
const result = await coordinator.advancePastUnplayable('user-1', SESSION_ID, {
expectedPlanVersion: 1,
planVersionId,
ordinal: 0,
trackId: TRACK_ID,
eventId,
});
expect(db.advancePastUnplayableVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', {
expectedPlanVersion: 1, planVersionId, ordinal: 0, trackId: TRACK_ID, eventId,
});
expect(result.now).toMatchObject({ track_id: TRACK_ID });
});
it('ends an active session once and preserves its latest persisted plan', async () => {
const { db, coordinator } = setup();
const response = await coordinator.end('user-1', SESSION_ID);
expect(db.endVibeSessionWithEvent).toHaveBeenCalledWith(SESSION_ID, 'user-1');
expect(response.session.status).toBe('ended');
expect(response.planVersion).toBe(1);
});
it('does not publish an initial plan when another start replaced the session while planning', async () => {
const { db, coordinator } = setup();
(db.publishVibePlan as any).mockRejectedValueOnce(new Error('Cannot publish a plan for replaced Vibe session'));
await expect(coordinator.start('user-1', {})).rejects.toBeInstanceOf(VibeSessionLifecycleError);
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ version: 1 }));
});
});
@@ -0,0 +1,418 @@
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
import { SessionDirector } from './session-director.service.js';
import { Candidate, VibeCalendarContext } from './generators.service.js';
/**
* This is deliberately a narrow bridge between the durable Vibe ledger and
* the current deterministic director. It writes authoritative revisions and
* lets the playback client replace only its unserved preview after feedback.
*/
export const DEFAULT_VIBE_POLICY_VERSION = 'vibe-v2-initial';
export const VIBE_EVENT_TYPES = [
'session_started', 'session_resumed', 'session_ended',
'plan_published', 'track_served', 'playback_started', 'progress', 'completed',
'skipped', 'disliked', 'kept', 'favourite_added', 'queue_removed',
'manual_search', 'album_opened', 'artist_opened', 'playlist_added',
'track_replayed', 'volume_changed', 'playback_error',
] as const;
export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number];
export interface StartVibeSessionInput {
seedTrackId?: string;
resumeSessionId?: string;
context?: VibeCalendarContext;
}
export interface AppendVibeEventInput {
eventId?: string;
type: VibeEventType;
trackId?: string;
occurredAt?: Date;
positionMs?: number;
durationMs?: number;
payload?: Record<string, unknown>;
}
/**
* An explicit advancement protocol for a plan item that was durably served
* but cannot be played locally. `eventId` is the idempotency key for this
* state transition; it is intentionally separate from a retry of /next.
*/
export interface AdvanceUnplayableVibeItemInput {
expectedPlanVersion: number;
planVersionId: string;
ordinal: number;
trackId: string;
eventId: string;
}
/**
* How much of the plan a client is shown and holds ready. The plan itself is
* PLAN_SIZE long and stays that way; this is only the served window. Every
* preview item costs the client one track fetch on every advance, and a replan
* discards whatever is still unplayed, so a long window buys little beyond a
* longer Up next list.
*/
const PREVIEW_SIZE = 3;
export class VibeSessionNotFoundError extends Error {}
export class VibeSessionLifecycleError extends Error {}
export class VibePlanNotFoundError extends Error {}
export interface VibeSessionResponse {
session: VibeSession;
sessionId: string;
planVersion: number | null;
now: VibePlan['items'][number] | null;
preview: VibePlan['items'];
state: Record<string, unknown>;
replanned: boolean;
replanReason: string | null;
}
export class VibeSessionCoordinator {
constructor(
private readonly db: DbService,
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState' | 'replan'>,
) {}
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId);
// Calendar context is a weak boot prior, never a substitute for listening
// feedback or long-term preference. It is intentionally coarse and is
// persisted with the session so a resume remains coherent.
const calendar = input.context;
const contextualEnergy = calendar && calendar.localHour < 6 ? 0.32
: calendar && calendar.localHour >= 18 && calendar.localHour < 23 ? 0.58
: 0.5;
const initialState = {
energy: contextualEnergy,
noveltyHunger: 0.3,
explorationCoefficient: 0.3,
discoveryRadius: 0.38,
sessionGoal: { type: 'discovery' as const, target: 1, progress: 0 },
};
const session = await this.db.createVibeSession({
userId,
policyVersion: DEFAULT_VIBE_POLICY_VERSION,
seedTrackId: input.seedTrackId ?? null,
context: calendar ? { ...calendar } : undefined,
profile: {
goals: initialState.sessionGoal,
explorationCoefficient: initialState.explorationCoefficient,
discoveryRadius: initialState.discoveryRadius,
},
});
// session_state is a derived cache used by the current director. Give it
// the durable ID so director state cannot accidentally bleed into another
// session while the durable tables remain the source of truth.
await this.db.createSessionState(
userId,
calendar ? JSON.stringify(calendar) : undefined,
{
energy: initialState.energy,
noveltyHunger: initialState.noveltyHunger,
explorationCoefficient: initialState.explorationCoefficient,
discoveryRadius: initialState.discoveryRadius,
sessionGoal: initialState.sessionGoal,
},
session.id,
);
await this.db.recordVibeEvent({
sessionId: session.id,
userId,
type: 'session_started',
payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION, ...(calendar ? { calendar } : {}) },
});
const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId);
const state = await this.director.buildState(userId, session.id);
let plan: VibePlan;
try {
plan = await this.db.publishVibePlan({
sessionId: session.id,
userId,
version: 1,
reason: 'session_started',
stateSnapshot: state,
objectiveSnapshot: {
policyVersion: DEFAULT_VIBE_POLICY_VERSION,
horizonTracks: candidates.length,
...(candidates[0]?.plan?.objective ?? {}),
},
items: candidates.map((candidate, ordinal) => ({
ordinal,
track_id: candidate.trackId,
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
candidate_source: candidate.generatorId,
score: candidate.plan?.score ?? candidate.relevance,
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
explanation: candidate.plan
? { paths: candidate.explanation, planner: candidate.plan.explanation }
: candidate.explanation,
committed: false,
})),
});
} catch (error) {
throw this.mapLifecycleError(error);
}
return this.toResponse(session, plan, state);
}
async getPlan(userId: string, sessionId: string, version?: number): Promise<VibeSessionResponse> {
const session = await this.requireSession(userId, sessionId);
const plan = await this.db.getVibePlan(sessionId, userId, version);
if (!plan) throw new VibePlanNotFoundError(
version === undefined ? 'Vibe session does not have a published plan' : 'Vibe plan revision was not found',
);
return this.toResponse(session, plan, plan?.state_snapshot ?? {});
}
async serveNext(userId: string, sessionId: string, expectedPlanVersion?: number): Promise<VibeSessionResponse> {
try {
const served = expectedPlanVersion === undefined
? await this.db.serveNextVibePlanItem(sessionId, userId)
: await this.db.serveNextVibePlanItem(sessionId, userId, expectedPlanVersion);
const response = await this.getPlan(userId, sessionId);
// A plan may be replaced between the client's preview and this request.
// In that case the database does not commit anything and this is the
// current, revisable preview the client must reconcile to.
if (served.stale) return response;
return { ...response, now: served.item, preview: response.preview };
} catch (error) {
throw this.mapLifecycleError(error);
}
}
async advancePastUnplayable(
userId: string,
sessionId: string,
input: AdvanceUnplayableVibeItemInput,
): Promise<VibeSessionResponse> {
try {
const served = await this.db.advancePastUnplayableVibePlanItem(sessionId, userId, input);
const response = await this.getPlan(userId, sessionId);
if (served.stale) return response;
return { ...response, now: served.item, preview: response.preview };
} catch (error) {
throw this.mapLifecycleError(error);
}
}
async appendEvent(
userId: string,
sessionId: string,
input: AppendVibeEventInput,
): Promise<VibeSessionResponse & { event: VibeEvent; idempotent: boolean }> {
try {
// Do this before the ledger write, rather than after it, because Vibe
// events are immutable. The DB repeats this boundary for non-HTTP
// callers; keeping it here also makes coordinator callers see exactly
// what will be persisted.
const result = await this.db.recordVibeEvent({
sessionId,
userId,
clientEventId: input.eventId,
type: input.type,
trackId: input.trackId,
occurredAt: input.occurredAt,
positionMs: input.positionMs,
durationMs: input.durationMs,
payload: input.payload,
});
// The ledger write is authoritative; this idempotent projection updates
// exploration only after the exact event exists. Keep the compatibility
// guard for old coordinator test doubles during the migration.
const projectSessionFeedback = (this.db as Partial<DbService>).projectVibeSessionFeedback;
if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event);
// A dislike in a Vibe is the same verdict as a dislike anywhere else. The
// ledger alone only excludes the track from this one session, which is
// why a disliked track kept coming back the next evening. Run it once per
// distinct event so a retried delivery cannot log a second feedback row.
if (input.type === 'disliked' && input.trackId && result.inserted) {
const dislikeTrack = (this.db as Partial<DbService>).dislikeTrack;
if (dislikeTrack) await dislikeTrack.call(this.db, userId, input.trackId);
}
if (!isMaterialFeedback(input.type)) {
const response = await this.getPlan(userId, sessionId);
return { ...response, event: result.event, idempotent: !result.inserted };
}
// A material event is durable before its computed replacement can be
// written. If planning/persistence failed after that event committed, a
// retry must finish the missing replacement instead of permanently
// returning an obsolete preview. Once a replacement exists, a duplicate
// retry returns that canonical revision without doing work again.
if (!result.inserted) {
const existingReplacement = await this.db.getVibePlanForFeedbackEvent(sessionId, userId, result.event.id);
if (existingReplacement) {
const session = await this.requireSession(userId, sessionId);
return {
...this.toResponse(session, existingReplacement, existingReplacement.state_snapshot),
event: result.event,
idempotent: true,
};
}
}
const session = await this.requireSession(userId, sessionId);
const state = await this.director.buildState(userId, sessionId);
// A feedback target is useful as the local replan anchor, but it must
// never displace the durable seed from the exclusion boundary. Unlike
// feedback tracks, the seed is not necessarily present in the event
// ledger, so carry it explicitly into every replacement request.
const seedTrackId = session.seed_track_id ?? undefined;
// Revisions retain the durable, unserved queue tail rather than building
// an unrelated plan after every signal. Besides reducing churn, this
// preserves a valid callback/recovery pair that has already been shown
// to the client while allowing the director to refill under the same
// hard caps and current feedback state.
const current = await this.db.getVibePlan(sessionId, userId);
const retained = current ? this.unservedCandidates(current) : [];
const excludedTrackIds = new Set<string>([
...(seedTrackId ? [seedTrackId] : []),
]);
const candidates = await this.director.replan(
userId,
sessionId,
retained,
input.trackId ? [input.trackId] : [],
input.trackId ?? seedTrackId,
{ excludedTrackIds },
);
const reason = `feedback:${input.type}`;
const plan = await this.db.publishVibePlan({
sessionId,
userId,
reason,
stateSnapshot: state,
objectiveSnapshot: {
policyVersion: session.policy_version,
feedbackEventId: result.event.id,
feedbackType: input.type,
horizonTracks: candidates.length,
...(candidates[0]?.plan?.objective ?? {}),
},
items: candidates.map((candidate, ordinal) => ({
ordinal,
track_id: candidate.trackId,
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
candidate_source: candidate.generatorId,
score: candidate.plan?.score ?? candidate.relevance,
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
explanation: candidate.plan
? { paths: candidate.explanation, planner: candidate.plan.explanation }
: candidate.explanation,
committed: false,
})),
});
return {
...this.toResponse(session, plan, state),
event: result.event,
idempotent: !result.inserted,
replanned: true,
replanReason: reason,
};
} catch (error) {
throw this.mapLifecycleError(error);
}
}
async end(userId: string, sessionId: string): Promise<VibeSessionResponse> {
let ended: VibeSession;
try {
ended = (await this.db.endVibeSessionWithEvent(sessionId, userId)).session;
} catch (error) {
throw this.mapLifecycleError(error);
}
const plan = await this.db.getVibePlan(sessionId, userId);
return this.toResponse(ended, plan, plan?.state_snapshot ?? {});
}
private async resume(userId: string, sessionId: string): Promise<VibeSessionResponse> {
try {
const resumed = await this.db.resumeVibeSession(sessionId, userId);
const plan = await this.db.getVibePlan(sessionId, userId);
return this.toResponse(resumed.session, plan, plan?.state_snapshot ?? {});
} catch (error) {
throw this.mapLifecycleError(error);
}
}
private async requireSession(userId: string, sessionId: string): Promise<VibeSession> {
const session = await this.db.getVibeSession(sessionId, userId);
if (!session) throw new VibeSessionNotFoundError('Vibe session was not found');
return session;
}
private toResponse(
session: VibeSession,
plan: VibePlan | null,
state: Record<string, unknown>,
): VibeSessionResponse {
// A revision is immutable, but clients need a live future: already served
// rows stay in the ledger and are excluded from the replacement preview.
const preview = plan?.items.filter((item) => !item.committed).slice(0, PREVIEW_SIZE) ?? [];
return {
session,
sessionId: session.id,
planVersion: plan?.version ?? null,
now: preview[0] ?? null,
preview,
state,
replanned: false,
replanReason: null,
};
}
/** Reconstruct the planner envelope from the durable revision. Older
* revisions stored only paths, so they remain valid retention inputs. */
private unservedCandidates(plan: VibePlan): Candidate[] {
return plan.items
.filter(item => !item.committed)
.map(item => {
const stored = item.explanation;
const hasPlanner = !!stored && !Array.isArray(stored) && typeof stored === 'object'
&& 'planner' in stored;
const object = hasPlanner ? stored as { paths?: Candidate['explanation']; planner?: Record<string, unknown> } : undefined;
const planner = object?.planner;
return {
trackId: item.track_id,
generatorId: item.candidate_source,
relevance: item.score,
explanation: object?.paths ?? (Array.isArray(stored) ? stored : []),
plan: planner ? {
slotRole: item.slot_role ?? 'retained',
score: item.score,
scoreBreakdown: item.score_breakdown,
explanation: planner,
objective: {
policy: planner.policy,
constraints: planner.constraints,
relaxations: planner.relaxations,
},
} : undefined,
};
});
}
private mapLifecycleError(error: unknown): Error {
if (error instanceof Error && (error.message.includes('Cannot record a new event for') || error.message.includes('Cannot resume ') || error.message.includes('Cannot publish a plan for'))) {
return new VibeSessionLifecycleError(error.message);
}
if (error instanceof Error && error.message.includes('not found or is not owned')) {
return new VibeSessionNotFoundError('Vibe session was not found');
}
return error instanceof Error ? error : new Error(String(error));
}
}
const MATERIAL_FEEDBACK_EVENTS = new Set<VibeEventType>(['skipped', 'disliked', 'completed', 'kept']);
function isMaterialFeedback(type: VibeEventType): boolean {
return MATERIAL_FEEDBACK_EVENTS.has(type);
}
+9 -2
View File
@@ -5,7 +5,6 @@ export interface MetadataRefreshJob {
export interface AudioAnalysisJob {
trackId: string;
features: string[];
}
export interface CleanupJob {
@@ -24,4 +23,12 @@ export interface ReprocessArtistsJob {
offset?: number;
}
export type JobPayload = MetadataRefreshJob | AudioAnalysisJob | CleanupJob | LibraryScanJob | ReindexTracksJob | ReprocessArtistsJob;
/**
* Explicit, opt-in System E hand-off. The worker looks the candidate up again
* from Postgres; no URL or shell arguments travel through Redis.
*/
export interface AcquisitionJob {
candidateId: string;
}
export type JobPayload = MetadataRefreshJob | AudioAnalysisJob | CleanupJob | LibraryScanJob | ReindexTracksJob | ReprocessArtistsJob | AcquisitionJob;
+19 -1
View File
@@ -57,7 +57,12 @@ services:
- backend
worker:
build: ./workers
build:
context: ./workers
# Enabled deliberately: the auto-seed discovery loop cannot acquire a
# candidate without the downloader present.
args:
INSTALL_YTDLP: "true"
restart: unless-stopped
network_mode: host
environment:
@@ -69,6 +74,19 @@ services:
DISCOGS_TOKEN: ${DISCOGS_TOKEN}
SOCKS_PROXY_URL: ${SOCKS_PROXY_URL}
MUSIC_DIR: /music
# System E acquisition, enabled. The allow-list is the only thing deciding
# where audio may come from, and it binds search-resolved URLs too.
# `www.youtube.com` is what yt-dlp reports as webpage_url for a ytsearch
# hit. Narrow this list to shrink the blast radius; empty it to stop
# acquisition without a rebuild.
MUZICK_ACQUISITION_ENABLED: "true"
MUZICK_ACQUISITION_YTDLP_PATH: /usr/bin/yt-dlp
MUZICK_ACQUISITION_ALLOWED_HOSTS: www.youtube.com
MUZICK_ACQUISITION_DIR: .recommendations
#
# Candidate generation crons (defaults shown, both daily).
# NEW_RELEASE_CRON: "30 5 * * *"
# RECOMMENDATION_CRON: "30 6 * * *"
# Hard-deletion gates for the dislike lifecycle (invariant §C). All three
# default to the safe value inside cleanup.service.ts; they are listed here
# as documentation and are intentionally left unset.
+7 -1
View File
@@ -2,7 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover lets the app paint under the Android gesture bar; the
transport pads itself back out with env(safe-area-inset-bottom). -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#14110D" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Muzick</title>
</head>
<body>
+62
View File
@@ -1,12 +1,60 @@
server {
listen 80;
# The nginx image ships with gzip off, so the bundle went out raw: 555KB of
# JS where gzip sends 167KB. Only text formats are listed — images, fonts
# (woff2) and audio are already compressed and would only burn CPU.
gzip on;
gzip_vary on;
gzip_min_length 1024;
# The public URL sits behind another proxy, which adds a Via header. With
# the default gzip_proxied off, nginx refuses to compress those responses.
gzip_proxied any;
# That same proxy speaks HTTP/1.0 upstream (nginx proxy_pass defaults to it),
# and gzip_http_version defaults to 1.1 — so without this nothing here is
# ever compressed, however the client asks.
gzip_http_version 1.0;
gzip_comp_level 6;
gzip_types
application/javascript
application/json
application/manifest+json
image/svg+xml
text/css
text/plain;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# A cached service worker or shell would pin the app to an old build, since
# both are the files that point at every hashed asset. Assets themselves are
# content-hashed by Vite, so they can be held forever.
# nginx's stock mime.types has no .webmanifest entry, so it would otherwise
# go out as application/octet-stream.
location = /manifest.webmanifest {
root /usr/share/nginx/html;
default_type application/manifest+json;
add_header Cache-Control "no-cache, must-revalidate";
}
location = /sw.js {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate";
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate";
}
location /assets/ {
root /usr/share/nginx/html;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Admin endpoints need the admin key, not the regular API key. This prefix
# location is longer than "/api", and nginx picks the longest matching
# prefix location, so it wins for /api/admin/* while /api handles the rest.
@@ -20,6 +68,20 @@ server {
proxy_cache_bypass $http_upgrade;
}
# The cross-device event stream is long-lived and must arrive unbuffered:
# with proxy buffering on, nginx holds each event until it has a chunk worth
# forwarding, which is exactly the latency this channel exists to avoid.
location /api/playback/stream {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Authorization "Bearer ${MUZICK_API_KEY}";
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
+5378 -42
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -6,6 +6,8 @@
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview"
},
"dependencies": {
@@ -21,13 +23,19 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.5.0",
"jsdom": "^25.0.1",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^5.2.0"
"vite": "^5.2.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^2.1.9"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+18 -10
View File
@@ -9,16 +9,18 @@ import { LyricsOverlay } from './LyricsOverlay';
import { Toaster } from './Toaster';
import { CommandPalette } from './CommandPalette';
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
import { Inspector, type InspectorMode } from './Inspector';
import { useInstallPrompt } from '../hooks/useInstallPrompt';
import { PlaybackSyncProvider } from './PlaybackSyncProvider';
export default function AppShell() {
const [queueOpen, setQueueOpen] = useState(false);
const [navigationOpen, setNavigationOpen] = useState(false);
const [lyricsOpen, setLyricsOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [inspector, setInspector] = useState<{ mode: InspectorMode; id: string } | null>(null);
const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []);
const closeInspector = useCallback(() => setInspector(null), []);
useInstallPrompt();
// Ctrl+K — command palette (uses `code` so it works on any keyboard layout)
useKeyboard({
@@ -39,24 +41,29 @@ export default function AppShell() {
handler: () => window.history.forward(),
});
// Esc — closes inspector, palette, etc.
useKeyboard({
code: 'Escape',
handler: () => {
if (inspector) closeInspector();
if (navigationOpen) setNavigationOpen(false);
else if (queueOpen) setQueueOpen(false);
else if (lyricsOpen) setLyricsOpen(false);
},
});
return (
<div className="flex flex-col h-screen bg-bg0 text-text overflow-hidden">
<PlaybackSyncProvider>
<div className="flex h-screen h-[100dvh] flex-col overflow-hidden bg-bg0 text-text">
<KeyboardListener />
<TopBar onToggleCommandPalette={togglePalette} />
<TopBar
onToggleCommandPalette={togglePalette}
onToggleNavigation={() => setNavigationOpen((open) => !open)}
navigationOpen={navigationOpen}
/>
<div className="relative flex flex-1 overflow-hidden">
<NavRail />
<main className="flex-1 overflow-y-auto p-4 pb-8">
<NavRail open={navigationOpen} onClose={() => setNavigationOpen(false)} />
<main className="min-w-0 flex-1 overflow-y-auto p-3 pb-6 sm:p-4 sm:pb-8">
<Outlet />
</main>
{inspector && <Inspector mode={inspector.mode} id={inspector.id} onClose={closeInspector} />}
{queueOpen && <NowPlayingPanel onClose={() => setQueueOpen(false)} />}
{lyricsOpen && <LyricsOverlay onClose={() => setLyricsOpen(false)} />}
</div>
@@ -70,5 +77,6 @@ export default function AppShell() {
<Toaster />
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
</PlaybackSyncProvider>
);
}
+3 -7
View File
@@ -24,13 +24,9 @@ function deduplicateArtists(artists: TrackArtist[]): TrackArtist[] {
map.set(a.id, a);
}
}
// Preserve original order, skipping duplicates.
const seen = new Set<string>();
return artists.filter((a) => {
if (seen.has(a.id)) return false;
seen.add(a.id);
return true;
});
// Keep the position of the selected credit and omit every duplicate. This
// makes the documented main-over-featured preference real.
return artists.filter((a) => map.get(a.id) === a);
}
/**
+7 -5
View File
@@ -9,6 +9,8 @@ interface ArtworkProps {
rounded?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
/** Skip native lazy-loading — set true for above-the-fold artwork (e.g. PlaybackBar). */
eager?: boolean;
/** Drop the note glyph. For callers that draw their own icon on top (TrackRow's play overlay). */
glyph?: boolean;
}
const API_BASE = import.meta.env.VITE_API_URL || '/api';
@@ -25,13 +27,13 @@ function proxySrc(src: string): string {
return src;
}
export function Artwork({ seed, src, className = '', rounded = 'md', eager = false }: ArtworkProps) {
export function Artwork({ seed, src, className = '', rounded = 'md', eager = false, glyph = true }: ArtworkProps) {
const hue = hueFromString(seed);
// Symmetric top sheen over a diagonal base — the highlight is centered
// horizontally so it reads as even behind the centered note glyph.
const gradient =
`radial-gradient(110% 90% at 50% 0%, hsl(${hue},55%,30%) 0%, transparent 60%), ` +
`linear-gradient(160deg, hsl(${hue},48%,23%), hsl(${(hue + 55) % 360},40%,11%))`;
`radial-gradient(110% 90% at 50% 0%, hsl(${hue},42%,26%) 0%, transparent 60%), ` +
`linear-gradient(160deg, hsl(${hue},36%,19%), hsl(${hue - 6},30%,10%))`;
const r = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl', full: 'rounded-full' }[rounded];
// If an image source is supplied we render it lazily over the gradient
@@ -45,7 +47,7 @@ export function Artwork({ seed, src, className = '', rounded = 'md', eager = fal
const proxied = proxySrc(src);
return (
<div className={`relative overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
{!loaded && <Music className="absolute inset-0 m-auto h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
{!loaded && glyph && <Music className="absolute inset-0 m-auto h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
<img
src={proxied}
alt={seed}
@@ -60,7 +62,7 @@ export function Artwork({ seed, src, className = '', rounded = 'md', eager = fal
}
return (
<div className={`relative flex items-center justify-center overflow-hidden ${r} ${className}`} style={{ background: gradient }}>
<Music className="h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />
{glyph && <Music className="h-2/5 w-2/5 text-on-accent/20" strokeWidth={1.5} aria-hidden />}
</div>
);
}
@@ -0,0 +1,178 @@
import { act, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
const { advancePastUnplayableVibeTrack, reportVibeEvent } = vi.hoisted(() => ({
advancePastUnplayableVibeTrack: vi.fn().mockResolvedValue(undefined),
reportVibeEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../services/vibeSession', () => ({ advancePastUnplayableVibeTrack, reportVibeEvent }));
import { AudioEngine } from './AudioEngine';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album',
duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
});
describe('AudioEngine', () => {
beforeEach(() => {
advancePastUnplayableVibeTrack.mockResolvedValue(undefined);
reportVibeEvent.mockResolvedValue(undefined);
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined);
useVibeStore.getState().reset();
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'song' });
usePlaybackStore.setState({
currentTrack: track('song'), queue: [track('song')], currentIndex: 0, isPlaying: false,
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined, audioElsewhere: false,
});
});
afterEach(() => vi.restoreAllMocks());
/** jsdom media elements report no duration and never really play. */
const fakeMedia = (audio: HTMLAudioElement, currentTime: number, duration = 180) => {
Object.defineProperty(audio, 'duration', { value: duration, configurable: true });
Object.defineProperty(audio, 'currentTime', { value: currentTime, writable: true, configurable: true });
Object.defineProperty(audio, 'paused', { value: false, configurable: true });
Object.defineProperty(audio, 'ended', { value: false, configurable: true });
};
it('loads no stream while another device holds the audio, and loads one when it comes back', () => {
usePlaybackStore.setState({
audioElsewhere: true, isPlaying: false, prefetchNext: true,
queue: [track('song'), track('next')], currentIndex: 0,
});
const { container } = render(<AudioEngine />);
const [active, idle] = Array.from(container.querySelectorAll('audio'));
expect(active.getAttribute('src')).toBeNull();
expect(idle.getAttribute('src')).toBeNull();
// Mirroring is not playback: this device reports nothing to the session.
expect(reportVibeEvent).not.toHaveBeenCalled();
act(() => usePlaybackStore.getState().setAudioElsewhere(false));
expect(active.src).toContain('/tracks/song/stream');
});
it('buffers the next queued track into the idle element before the current one ends', () => {
usePlaybackStore.setState({
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
});
const { container } = render(<AudioEngine />);
const [active, idle] = Array.from(container.querySelectorAll('audio'));
fakeMedia(active, 170);
active.dispatchEvent(new Event('timeupdate'));
expect(idle.src).toContain('/tracks/next/stream');
expect(active.src).toContain('/tracks/song/stream');
});
it('starts buffering the next track early in the current one, not only near its end', () => {
usePlaybackStore.setState({
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
});
const { container } = render(<AudioEngine />);
const [active, idle] = Array.from(container.querySelectorAll('audio'));
fakeMedia(active, 20);
active.dispatchEvent(new Event('timeupdate'));
expect(idle.src).toContain('/tracks/next/stream');
});
it('re-points the buffered element when a replan changes what plays next', () => {
usePlaybackStore.setState({
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
});
const { container } = render(<AudioEngine />);
const [active, idle] = Array.from(container.querySelectorAll('audio'));
fakeMedia(active, 20);
active.dispatchEvent(new Event('timeupdate'));
expect(idle.src).toContain('/tracks/next/stream');
usePlaybackStore.setState({ queue: [track('song'), track('other')] });
fakeMedia(active, 30);
active.dispatchEvent(new Event('timeupdate'));
expect(idle.src).toContain('/tracks/other/stream');
});
it('hands over to the next track inside the crossfade window instead of waiting for ended', () => {
usePlaybackStore.setState({
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 500,
});
const { container } = render(<AudioEngine />);
const [active, idle] = Array.from(container.querySelectorAll('audio'));
fakeMedia(active, 179.8);
active.dispatchEvent(new Event('timeupdate'));
expect(usePlaybackStore.getState().currentTrack?.id).toBe('next');
expect(idle.src).toContain('/tracks/next/stream');
});
it('joins gaplessly with crossfade off: resolves early, starts the next track when the tail ends', () => {
usePlaybackStore.setState({
queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
});
const { container } = render(<AudioEngine />);
const [active, idle] = Array.from(container.querySelectorAll('audio'));
const idlePlay = vi.spyOn(idle, 'play').mockResolvedValue(undefined);
fakeMedia(active, 179);
active.dispatchEvent(new Event('timeupdate'));
// The store moved on so Vibe can resolve, but the tail keeps the audio.
expect(usePlaybackStore.getState().currentTrack?.id).toBe('next');
expect(idle.src).toContain('/tracks/next/stream');
expect(idlePlay).not.toHaveBeenCalled();
Object.defineProperty(active, 'ended', { value: true, configurable: true });
active.dispatchEvent(new Event('ended'));
expect(idlePlay).toHaveBeenCalled();
});
it('does not resume the finished element while the next track is still being resolved', () => {
usePlaybackStore.setState({
queue: [track('song')], currentIndex: 0, isPlaying: true,
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined, crossfadeMs: 0,
});
const { container } = render(<AudioEngine />);
const [active] = Array.from(container.querySelectorAll('audio'));
const play = vi.spyOn(active, 'play').mockResolvedValue(undefined);
Object.defineProperty(active, 'ended', { value: true, configurable: true });
Object.defineProperty(active, 'paused', { value: true, configurable: true });
active.dispatchEvent(new Event('ended'));
// A queue/plan write during the Vibe handshake must not restart the tail.
usePlaybackStore.getState().setQueue([track('song')]);
expect(play).not.toHaveBeenCalled();
});
it('uses the durable unplayable advancement when a Vibe stream errors after metadata resolved', async () => {
const { container } = render(<AudioEngine />);
const audio = container.querySelector('audio')!;
audio.dispatchEvent(new Event('error'));
audio.dispatchEvent(new Event('error'));
await waitFor(() => expect(advancePastUnplayableVibeTrack).toHaveBeenCalledWith('song'));
expect(advancePastUnplayableVibeTrack).toHaveBeenCalledTimes(1);
expect(reportVibeEvent).not.toHaveBeenCalledWith('skipped', 'song');
});
});
+463 -85
View File
@@ -1,18 +1,11 @@
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { trackService } from '../services/trackService';
import { vibeService } from '../services/vibeService';
import { advancePastUnplayableVibeTrack, reportVibeEvent } from '../services/vibeSession';
import { PREFETCH_LEAD_SECONDS, PREFETCH_START_SECONDS } from '../lib/playbackPrefs';
import type { Track } from '../types';
// Track ids whose next natural feedback transition should be skipped because
// the caller (e.g. Vibe.tsx's dislike button) already recorded feedback for
// them explicitly. Consumed once, then cleared.
const suppressedFeedbackIds = new Set<string>();
export function suppressAutoFeedback(trackId: string): void {
suppressedFeedbackIds.add(trackId);
}
// Threshold (seconds) above which a store position change is treated as a user
// scrub and applied to the audio element. Keeps the timeupdate -> setPosition ->
// effect loop from fighting itself.
@@ -25,6 +18,18 @@ const COMPLETION_THRESHOLD = 0.95;
// Relative seek increment (seconds) for MediaSession seekforward/seekbackward.
const SEEK_INCREMENT = 10;
// Fade granularity. Fine enough to be inaudible, coarse enough to be cheap.
const FADE_TICK_MS = 40;
// With crossfade off there is no overlap to hide Vibe's replan round-trip, so
// the handover still starts this early — the next element just waits, silent,
// until the current one actually ends.
const HANDOFF_LEAD_MS = 1200;
// If the outgoing element never reports `ended` (a stalled or broken stream),
// start the waiting track anyway this long after its lead began.
const JOIN_TIMEOUT_MS = HANDOFF_LEAD_MS + 2000;
/** Build the artwork URLs for MediaSession metadata (OS media controls). */
function buildArtwork(track: Track): MediaImage[] {
const sizes = [96, 128, 192, 256, 384, 512];
@@ -37,26 +42,219 @@ function buildArtwork(track: Track): MediaImage[] {
return sizes.map((s) => ({ src: url, sizes: `${s}x${s}`, type: 'image/jpeg' }));
}
// Headless audio engine: one shared <audio> element driven by the playback store.
// State -> DOM via store subscriptions; DOM -> state via media events.
export const AudioEngine = () => {
const audioRef = useRef<HTMLAudioElement>(null);
/**
* The id of the track ordinary queue navigation will play next, or null when it
* cannot be known ahead of time (shuffle) or there is nothing after this one.
* Vibe's next track comes from the server, so this is only a prediction there —
* used to warm the stream, never to decide what actually plays.
*/
function predictNextTrackId(): string | null {
const { queue, currentTrack, currentIndex, shuffle, repeat } = usePlaybackStore.getState();
if (shuffle || repeat === 'one' || queue.length === 0) return null;
const idx =
currentIndex >= 0 && queue[currentIndex]?.id === currentTrack?.id
? currentIndex
: currentTrack
? queue.findIndex((t) => t.id === currentTrack.id)
: -1;
if (idx < 0) return null;
if (idx + 1 < queue.length) return queue[idx + 1].id;
if (repeat === 'all') return queue[0].id;
return null;
}
// Track which id is currently loaded into the element, and whether it ended
// naturally (so we record COMPLETED, not skip, on the resulting track change).
// Headless audio engine: two <audio> elements driven by the playback store, so
// the next track can buffer (and fade in) while the current one is still
// playing. State -> DOM via store subscriptions; DOM -> state via media events.
export const AudioEngine = () => {
const audioARef = useRef<HTMLAudioElement>(null);
const audioBRef = useRef<HTMLAudioElement>(null);
// Index of the element the store's currentTrack is playing on; the other one
// is idle, prefetching, or fading out after being retired.
const activeIdxRef = useRef(0);
// Per-element fade multiplier applied on top of the store's master volume.
const gainRef = useRef([1, 1]);
const fadeTimerRef = useRef<Array<ReturnType<typeof setInterval> | null>>([null, null]);
// The idle element holds this track id, loaded but never yet played.
const preparedRef = useRef<{ id: string; idx: number } | null>(null);
// Set when the handover window triggered the track change before `ended`, so
// the ending element's own event does not advance a second time.
const earlyAdvanceRef = useRef(false);
// Cancels a pending gapless join (next track loaded, waiting for the tail).
const cancelJoinRef = useRef<(() => void) | null>(null);
// Track which id is currently loaded into the active element, and whether it
// ended naturally (so we record COMPLETED, not skip, on the track change).
const loadedIdRef = useRef<string | null>(null);
const endedNaturallyRef = useRef(false);
// Track whether the current track has crossed the completion threshold.
const crossedThresholdRef = useRef(false);
const lastProgressSecondRef = useRef(-1);
const streamErrorTrackIdRef = useRef<string | null>(null);
const elements = useCallback((): HTMLAudioElement[] => {
const a = audioARef.current;
const b = audioBRef.current;
return a && b ? [a, b] : [];
}, []);
const applyGain = useCallback((idx: number) => {
const els = elements();
if (!els[idx]) return;
const master = usePlaybackStore.getState().volume;
els[idx].volume = Math.min(1, Math.max(0, master * gainRef.current[idx]));
}, [elements]);
const stopFade = useCallback((idx: number) => {
const timer = fadeTimerRef.current[idx];
if (timer !== null) {
clearInterval(timer);
fadeTimerRef.current[idx] = null;
}
}, []);
const setGain = useCallback((idx: number, gain: number) => {
stopFade(idx);
gainRef.current[idx] = Math.min(1, Math.max(0, gain));
applyGain(idx);
}, [applyGain, stopFade]);
/** Ramp one element's gain, then run `onDone`. Instant when ms <= 0. */
const fadeTo = useCallback((idx: number, target: number, ms: number, onDone?: () => void) => {
stopFade(idx);
if (ms <= 0) {
setGain(idx, target);
onDone?.();
return;
}
const from = gainRef.current[idx];
const steps = Math.max(1, Math.round(ms / FADE_TICK_MS));
let step = 0;
fadeTimerRef.current[idx] = setInterval(() => {
step += 1;
gainRef.current[idx] = from + ((target - from) * step) / steps;
applyGain(idx);
if (step >= steps) {
stopFade(idx);
setGain(idx, target);
onDone?.();
}
}, FADE_TICK_MS);
}, [applyGain, setGain, stopFade]);
/** Take an element out of service: silence it and drop its stream. */
const retire = useCallback((idx: number) => {
const els = elements();
const el = els[idx];
if (!el) return;
stopFade(idx);
el.pause();
el.removeAttribute('src');
el.load();
setGain(idx, 1);
}, [elements, setGain, stopFade]);
const cancelJoin = useCallback(() => {
const cancel = cancelJoinRef.current;
cancelJoinRef.current = null;
cancel?.();
}, []);
/**
* Crossfade-off handover: the next track is already loaded and the store has
* already moved on, but the outgoing element still has its tail to play.
* Start the new one the moment that tail ends, so the join has no gap and no
* overlap.
*/
const scheduleJoin = useCallback((oldIdx: number, nextIdx: number) => {
const els = elements();
if (els.length === 0) return;
const oldEl = els[oldIdx];
let settled = false;
const cleanup = () => {
clearTimeout(timer);
oldEl.removeEventListener('ended', join);
oldEl.removeEventListener('error', join);
};
function join() {
if (settled) return;
settled = true;
cleanup();
cancelJoinRef.current = null;
retire(oldIdx);
// A newer track change may already own the pipeline.
if (activeIdxRef.current !== nextIdx) return;
if (!usePlaybackStore.getState().isPlaying) return;
setGain(nextIdx, 1);
void els[nextIdx].play().catch(() => {});
}
const timer = setTimeout(join, JOIN_TIMEOUT_MS);
oldEl.addEventListener('ended', join);
oldEl.addEventListener('error', join);
cancelJoinRef.current = () => {
if (settled) return;
settled = true;
cleanup();
retire(oldIdx);
};
}, [elements, retire, setGain]);
// --- DOM -> store: media events -----------------------------------------
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const els = elements();
if (els.length === 0) return;
const store = usePlaybackStore.getState;
const onTimeUpdate = () => {
/** Warm the predicted next track into the idle element. */
const prefetch = () => {
const playback = store();
if (!playback.prefetchNext || playback.audioElsewhere) return;
const nextId = predictNextTrackId();
if (!nextId || nextId === loadedIdRef.current) return;
const idleIdx = 1 - activeIdxRef.current;
// Buffering starts early enough that a Vibe replan can change the answer
// underneath us. Re-point the idle element rather than arriving at the
// handover with the wrong track warmed; leave a tail still playing alone.
const prepared = preparedRef.current;
if (prepared && (prepared.id === nextId || prepared.idx !== idleIdx)) return;
const idle = els[idleIdx];
if (prepared && !idle.paused) return;
setGain(idleIdx, 1);
idle.preload = 'auto';
idle.src = trackService.getStreamUrl(nextId);
idle.load();
preparedRef.current = { id: nextId, idx: idleIdx };
};
/**
* Hand over to the next track before `ended`, so Vibe's feedback/replan
* round-trip happens while the tail of this one is still playing. With a
* crossfade the tail fades under the new track; without one the new track
* waits, silent, for the tail to finish.
*/
const maybeAdvanceEarly = (audio: HTMLAudioElement) => {
const playback = store();
const leadMs = playback.crossfadeMs > 0 ? playback.crossfadeMs : HANDOFF_LEAD_MS;
if (earlyAdvanceRef.current || !playback.isPlaying) return;
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return;
if (audio.duration * 1000 <= leadMs * 2) return;
if (audio.duration - audio.currentTime > leadMs / 1000) return;
const vibeOwned = playback.queueOwner === 'vibe' && !!playback.vibeAdvanceHandler;
// Ordinary playback must have somewhere to go; otherwise let the element
// finish on its own so end-of-queue still stops cleanly.
if (!vibeOwned && !predictNextTrackId()) return;
earlyAdvanceRef.current = true;
endedNaturallyRef.current = true;
store().nextWithReason('completed');
};
const onTimeUpdate = (idx: number, audio: HTMLAudioElement) => {
if (idx !== activeIdxRef.current) return;
store().setPosition(audio.currentTime);
// Mark as effectively completed if we cross the threshold.
if (
@@ -66,137 +264,270 @@ export const AudioEngine = () => {
) {
crossedThresholdRef.current = true;
}
const vibe = useVibeStore.getState();
const track = store().currentTrack;
const elapsed = Math.floor(audio.currentTime);
if (vibe.activeSessionId && store().queueOwner === 'vibe' && track && elapsed > 0 && elapsed % 30 === 0 && elapsed !== lastProgressSecondRef.current) {
lastProgressSecondRef.current = elapsed;
void reportVibeEvent('progress', track.id, Math.round(audio.currentTime * 1000), Math.round((audio.duration || 0) * 1000)).catch(() => undefined);
}
if (
audio.currentTime >= PREFETCH_START_SECONDS ||
(Number.isFinite(audio.duration) && audio.duration - audio.currentTime <= PREFETCH_LEAD_SECONDS)
) {
prefetch();
}
maybeAdvanceEarly(audio);
};
const onLoadedMetadata = () => {
const onLoadedMetadata = (idx: number, audio: HTMLAudioElement) => {
// The idle element's metadata says nothing about what is playing.
if (idx !== activeIdxRef.current) return;
if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
};
const onPlay = () => {
const onPlay = (idx: number) => {
if (idx !== activeIdxRef.current) return;
if (!store().isPlaying) store().play();
};
const onPause = () => {
const onPause = (idx: number, audio: HTMLAudioElement) => {
if (idx !== activeIdxRef.current) return;
// Ignore the pause that fires as part of ending a track.
if (audio.ended) return;
if (store().isPlaying) store().pause();
};
const onEnded = () => {
const onEnded = (idx: number, audio: HTMLAudioElement) => {
// A retired element reaching its end is expected during a crossfade.
if (idx !== activeIdxRef.current) return;
// The crossfade window already handed over; do not advance twice.
if (earlyAdvanceRef.current) {
audio.pause();
return;
}
// Just flag it — applyTrack (below) is the single place that sends
// feedback, on the resulting track-change, so completion is recorded
// exactly once per track.
endedNaturallyRef.current = true;
store().next();
// Vibe's durable feedback/replan handshake can take longer than the
// browser's end transition. Explicitly pause the ended element so it
// cannot auto-resume from its final buffered samples while that work is
// in flight. applyTrack will start the next source when ready.
audio.pause();
store().nextWithReason('completed');
};
const onError = (idx: number) => {
if (idx !== activeIdxRef.current) return;
const playback = store();
const track = playback.currentTrack;
const vibe = useVibeStore.getState();
// Metadata can be available while the stream itself is no longer
// readable. Vibe must advance that exact durable cursor, not fall back
// to ordinary queue navigation or feedback-driven replanning.
if (!track || !vibe.activeSessionId || playback.queueOwner !== 'vibe' || streamErrorTrackIdRef.current === track.id) return;
streamErrorTrackIdRef.current = track.id;
void advancePastUnplayableVibeTrack(track.id)
.catch(() => undefined)
.finally(() => { streamErrorTrackIdRef.current = null; });
};
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('loadedmetadata', onLoadedMetadata);
audio.addEventListener('play', onPlay);
audio.addEventListener('pause', onPause);
audio.addEventListener('ended', onEnded);
const teardown = els.map((audio, idx) => {
const listeners: Array<[string, () => void]> = [
['timeupdate', () => onTimeUpdate(idx, audio)],
['loadedmetadata', () => onLoadedMetadata(idx, audio)],
['play', () => onPlay(idx)],
['pause', () => onPause(idx, audio)],
['ended', () => onEnded(idx, audio)],
['error', () => onError(idx)],
];
for (const [event, handler] of listeners) audio.addEventListener(event, handler);
return () => {
for (const [event, handler] of listeners) audio.removeEventListener(event, handler);
};
});
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
audio.removeEventListener('play', onPlay);
audio.removeEventListener('pause', onPause);
audio.removeEventListener('ended', onEnded);
};
}, []);
return () => { for (const off of teardown) off(); };
}, [elements, setGain]);
// --- store -> DOM: react to currentTrack changes ------------------------
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const els = elements();
if (els.length === 0) return;
const applyTrack = (id: string | null) => {
// Another device is playing and this one is only showing what it plays.
// Pointing an element at the stream here would download the track — the
// whole of it, once an earlier prefetch left that element on preload=auto
// — for audio that is never heard. Drop the sources and follow along.
if (usePlaybackStore.getState().audioElsewhere) {
cancelJoin();
for (const idx of [0, 1]) retire(idx);
loadedIdRef.current = null;
preparedRef.current = null;
endedNaturallyRef.current = false;
crossedThresholdRef.current = false;
earlyAdvanceRef.current = false;
return;
}
if (id === loadedIdRef.current) return;
// A newer track change supersedes any tail still waiting to hand over.
cancelJoin();
// The previously loaded track is changing. If it didn't end naturally and
// hadn't crossed the completion threshold, record a skip (best-effort).
// If it crossed the threshold OR ended naturally, record as completed.
// The durable Vibe controller owns normal next/ended navigation. It
// records the outcome, receives a new plan revision, then calls the raw
// advance method. Do not emit a second event here after that transition.
const prevId = loadedIdRef.current;
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
// Only vibe sessions want this feedback — plain library browsing
// shouldn't write skip/completed evidence for tracks merely sampled.
const inVibeSession = !!useVibeStore.getState().activeSessionId;
if (prevId && inVibeSession) {
if (suppressedFeedbackIds.delete(prevId)) {
// Caller already recorded explicit feedback (e.g. dislike) for
// this track — don't also record the implicit transition.
} else {
try {
void vibeService.feedback(prevId, completed ? 'completed' : 'skipped').catch(() => {});
} catch {
/* best-effort */
}
}
const playback = usePlaybackStore.getState();
const inVibePlayback = !!useVibeStore.getState().activeSessionId && playback.queueOwner === 'vibe';
if (prevId && inVibePlayback && !playback.vibeAdvanceHandler) {
void reportVibeEvent(completed ? 'completed' : 'skipped', prevId).catch(() => undefined);
}
const wasEarlyAdvance = earlyAdvanceRef.current;
endedNaturallyRef.current = false;
crossedThresholdRef.current = false;
earlyAdvanceRef.current = false;
lastProgressSecondRef.current = -1;
loadedIdRef.current = id;
const oldIdx = activeIdxRef.current;
const oldEl = els[oldIdx];
if (!id) {
audio.removeAttribute('src');
audio.load();
retire(oldIdx);
return;
}
audio.src = trackService.getStreamUrl(id);
audio.load();
// Reuse the prefetched element when it holds exactly this track; that
// stream is already buffered, so playback starts without a fetch. With
// nothing playing yet there is no tail to preserve, so load in place.
const prepared = preparedRef.current;
const reusePrefetched = prepared?.id === id && prepared.idx !== oldIdx;
const nextIdx = reusePrefetched ? prepared!.idx : prevId ? 1 - oldIdx : oldIdx;
const nextEl = els[nextIdx];
preparedRef.current = null;
if (!reusePrefetched) {
nextEl.src = trackService.getStreamUrl(id);
nextEl.load();
} else if (nextEl.currentTime > 0) {
nextEl.currentTime = 0;
}
activeIdxRef.current = nextIdx;
// Only an early handover leaves a tail to deal with; an element that
// already ended has nothing left to play.
const crossfadeMs = playback.crossfadeMs;
const hasTail = wasEarlyAdvance && nextIdx !== oldIdx && !oldEl.paused && !oldEl.ended;
const fadeMs = hasTail ? crossfadeMs : 0;
// Tail with no crossfade: hold the new track until the tail is done.
const deferStart = hasTail && crossfadeMs <= 0;
if (nextIdx !== oldIdx) {
if (fadeMs > 0) fadeTo(oldIdx, 0, fadeMs, () => retire(oldIdx));
else if (deferStart) scheduleJoin(oldIdx, nextIdx);
else retire(oldIdx);
}
if (inVibePlayback) void reportVibeEvent('playback_started', id).catch(() => undefined);
setGain(nextIdx, fadeMs > 0 ? 0 : 1);
if (deferStart) return;
if (usePlaybackStore.getState().isPlaying) {
void audio.play().catch(() => {});
void nextEl.play().catch(() => {});
if (fadeMs > 0) fadeTo(nextIdx, 1, fadeMs);
} else if (fadeMs > 0) {
setGain(nextIdx, 1);
}
};
// Apply the current value immediately, then subscribe to future changes.
let lastElsewhere = usePlaybackStore.getState().audioElsewhere;
applyTrack(usePlaybackStore.getState().currentTrack?.id ?? null);
const unsub = usePlaybackStore.subscribe((state) => {
if (state.audioElsewhere !== lastElsewhere) {
lastElsewhere = state.audioElsewhere;
// The audio just came back to this device. Nothing is loaded, and the
// track id has not changed, so say so and let applyTrack load it.
if (!lastElsewhere) loadedIdRef.current = null;
}
applyTrack(state.currentTrack?.id ?? null);
});
return unsub;
}, []);
}, [cancelJoin, elements, fadeTo, retire, scheduleJoin, setGain]);
// --- store -> DOM: isPlaying ------------------------------------------------
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const els = elements();
if (els.length === 0) return;
const apply = (isPlaying: boolean) => {
const audio = els[activeIdxRef.current];
// Nothing is loaded while another device holds the audio.
if (usePlaybackStore.getState().audioElsewhere) return;
if (isPlaying) {
// Never resume a finished element. Between `ended` and the next source
// being loaded, isPlaying is still true, and resuming here replays the
// final buffered milliseconds of the track that just finished.
if (audio.ended) return;
if (audio.paused) void audio.play().catch(() => {});
} else {
if (!audio.paused) audio.pause();
// Pausing ends the handover: the tail is dropped and the waiting track
// becomes the one that resumes.
cancelJoin();
for (const el of els) if (!el.paused) el.pause();
}
};
apply(usePlaybackStore.getState().isPlaying);
return usePlaybackStore.subscribe((state) => apply(state.isPlaying));
}, []);
// Gate on an actual change: the subscription is unselected, so it fires on
// every store write, including the queue/plan writes that happen while
// Vibe's advance handshake is in flight.
let lastIsPlaying = usePlaybackStore.getState().isPlaying;
apply(lastIsPlaying);
return usePlaybackStore.subscribe((state) => {
if (state.isPlaying === lastIsPlaying) return;
lastIsPlaying = state.isPlaying;
apply(lastIsPlaying);
});
}, [cancelJoin, elements]);
// --- store -> DOM: volume --------------------------------------------------
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const els = elements();
if (els.length === 0) return;
const apply = (volume: number) => {
audio.volume = Math.min(1, Math.max(0, volume));
};
// Master volume is scaled by each element's fade gain.
const apply = () => els.forEach((_, idx) => applyGain(idx));
apply(usePlaybackStore.getState().volume);
return usePlaybackStore.subscribe((state) => apply(state.volume));
}, []);
let lastVolume = usePlaybackStore.getState().volume;
apply();
return usePlaybackStore.subscribe((state) => {
if (state.volume === lastVolume) return;
lastVolume = state.volume;
apply();
});
}, [applyGain, elements]);
// --- store -> DOM: external seeks (user scrubbing) -------------------------
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const els = elements();
if (els.length === 0) return;
const apply = (position: number) => {
const audio = els[activeIdxRef.current];
// A mirrored position belongs to another device's playhead, and there is
// no source loaded here to move anyway.
if (usePlaybackStore.getState().audioElsewhere) return;
if (Math.abs(audio.currentTime - position) > SEEK_THRESHOLD) {
audio.currentTime = position;
}
};
return usePlaybackStore.subscribe((state) => apply(state.position));
}, []);
// Only real position changes are seeks; other store writes must not move
// the playhead of a track that is mid-transition.
let lastPosition = usePlaybackStore.getState().position;
return usePlaybackStore.subscribe((state) => {
if (state.position === lastPosition) return;
lastPosition = state.position;
apply(lastPosition);
});
}, [elements]);
// --- MediaSession: hardware media keys + OS media controls ------------------
//
@@ -209,6 +540,7 @@ export const AudioEngine = () => {
if (!('mediaSession' in navigator)) return;
const store = usePlaybackStore.getState;
const active = () => elements()[activeIdxRef.current] ?? null;
const handlers: Partial<Record<MediaSessionAction, (details: MediaSessionActionDetails) => void>> = {
play: () => store().play(),
@@ -216,19 +548,19 @@ export const AudioEngine = () => {
previoustrack: () => store().prev(),
nexttrack: () => store().next(),
seekbackward: (details) => {
const audio = audioRef.current;
const audio = active();
if (!audio) return;
const delta = details.seekOffset ?? SEEK_INCREMENT;
audio.currentTime = Math.max(0, audio.currentTime - delta);
},
seekforward: (details) => {
const audio = audioRef.current;
const audio = active();
if (!audio) return;
const delta = details.seekOffset ?? SEEK_INCREMENT;
audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + delta);
},
seekto: (details) => {
const audio = audioRef.current;
const audio = active();
if (!audio || details.seekTime == null) return;
audio.currentTime = details.seekTime;
},
@@ -262,7 +594,7 @@ export const AudioEngine = () => {
}
}
};
}, []);
}, [elements]);
// --- MediaSession: publish metadata + playback state ------------------------
useEffect(() => {
@@ -289,5 +621,51 @@ export const AudioEngine = () => {
});
}, []);
return <audio ref={audioRef} hidden />;
// --- MediaSession: publish position -----------------------------------------
//
// Without a position state the Android notification renders a scrubber that is
// stuck at zero. setPositionState throws if position exceeds duration, which
// happens transiently at a track handover, so both are clamped.
useEffect(() => {
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
const publish = (position: number, duration: number) => {
if (!Number.isFinite(duration) || duration <= 0) return;
try {
navigator.mediaSession.setPositionState({
duration,
position: Math.min(Math.max(position, 0), duration),
playbackRate: 1,
});
} catch {
// Stale position against a just-changed duration — the next tick fixes it.
}
};
let lastPosition = -1;
let lastDuration = -1;
return usePlaybackStore.subscribe((state) => {
// timeupdate fires ~4x a second; only republish on a whole-second change.
const second = Math.floor(state.position);
if (second === lastPosition && state.duration === lastDuration) return;
lastPosition = second;
lastDuration = state.duration;
publish(state.position, state.duration);
});
}, []);
// Drop in-flight fades and any pending handover when the engine unmounts.
useEffect(() => () => {
for (const timer of fadeTimerRef.current) if (timer !== null) clearInterval(timer);
cancelJoinRef.current?.();
cancelJoinRef.current = null;
}, []);
return (
<>
<audio ref={audioARef} hidden />
<audio ref={audioBRef} hidden />
</>
);
};
+5 -3
View File
@@ -32,7 +32,7 @@ const NAV_COMMANDS: CommandItem[] = [
{ id: 'nav-artists', label: 'Artists', icon: Users, action: () => {}, keywords: ['artists', 'bands'] },
{ id: 'nav-genres', label: 'Genres', icon: Tag, action: () => {}, keywords: ['genres', 'tags', 'categories'] },
{ id: 'nav-vibe', label: 'Vibe', description: 'Endless recommendations', icon: Zap, action: () => {}, keywords: ['vibe', 'recommendations', 'radio'] },
{ id: 'nav-discover', label: 'Discover', description: 'Browse by genre', icon: Compass, action: () => {}, keywords: ['discover', 'explore'] },
{ id: 'nav-recommendations', label: 'Found', description: 'Discovered tracks and their fate', icon: Compass, action: () => {}, keywords: ['found', 'discovery', 'recommendations', 'probation', 'new releases'] },
{ id: 'nav-quarantine', label: 'Quarantine', icon: ShieldAlert, action: () => {}, keywords: ['quarantine', 'disliked', 'trash'] },
{ id: 'nav-jobs', label: 'Jobs', description: 'Background tasks', icon: Terminal, action: () => {}, keywords: ['jobs', 'tasks', 'queue'] },
{ id: 'nav-settings', label: 'Settings', icon: Settings, action: () => {}, keywords: ['settings', 'preferences', 'config'] },
@@ -63,7 +63,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
'nav-artists': '/artists',
'nav-genres': '/genres',
'nav-vibe': '/vibe',
'nav-discover': '/discover',
'nav-recommendations': '/recommendations',
'nav-quarantine': '/quarantine',
'nav-jobs': '/jobs',
'nav-settings': '/settings',
@@ -140,7 +140,9 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
aria-hidden="true"
/>
{/* Dialog */}
<div className="fixed left-1/2 top-[15vh] z-50 w-full max-w-lg -translate-x-1/2 animate-rise">
{/* Centred with margins, not a translate: animate-rise sets its own
transform and would drop a -translate-x-1/2 on the same element. */}
<div className="fixed inset-x-4 top-[15vh] z-50 mx-auto max-w-lg animate-rise">
<div className="overflow-hidden rounded-lg border border-border bg-bg1 shadow-2xl shadow-black/60">
{/* Search input */}
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef, useState } from 'react';
import { Laptop, MonitorSpeaker, Smartphone } from 'lucide-react';
import { usePlaybackSyncContext } from './PlaybackSyncProvider';
/**
* Device menu. Picking a device moves the audio there: the chosen browser
* resumes the same track at the same position, and the one that had it stops.
*/
export function DevicePicker() {
const { devices, deviceId, isOwner, hasRemoteOwner, transferTo } = usePlaybackSyncContext();
const [open, setOpen] = useState(false);
const wrapper = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (!wrapper.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
}, [open]);
const online = devices.filter((device) => device.online || device.id === deviceId);
// Nothing to switch between, so the control would only take up room.
if (online.length < 2 && !hasRemoteOwner) return null;
const owner = devices.find((device) => device.isOwner) ?? null;
return (
<div className="relative" ref={wrapper}>
<button
onClick={() => setOpen((value) => !value)}
className={`rounded-md p-2 transition-colors ${
hasRemoteOwner ? 'bg-accent/20 text-accent' : 'text-muted hover:bg-surface0 hover:text-text'
}`}
aria-label="Playback device"
aria-expanded={open}
title={hasRemoteOwner && owner ? `Playing on ${owner.name}` : 'Playing on this device'}
>
<MonitorSpeaker size={18} />
</button>
{open && (
<div
role="menu"
className="absolute bottom-full right-0 z-50 mb-2 w-60 overflow-hidden rounded-lg border border-border bg-bg1 shadow-2xl shadow-black/60"
>
<div className="border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted">
Play on
</div>
{online.map((device) => {
const isThis = device.id === deviceId;
return (
<button
key={device.id}
role="menuitem"
onClick={() => {
setOpen(false);
if (!device.isOwner) void transferTo(device.id);
}}
className={`flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-surface0 ${
device.isOwner ? 'text-accent' : 'text-text'
}`}
>
{/Android|iPhone|iPad/i.test(device.name) ? <Smartphone size={16} /> : <Laptop size={16} />}
<span className="min-w-0 flex-1 truncate">
{device.name}
{isThis && <span className="text-muted"> · this device</span>}
</span>
{device.isOwner && <span className="text-xs text-accent">playing</span>}
</button>
);
})}
{!isOwner && !hasRemoteOwner && (
<div className="border-t border-border px-3 py-2 text-xs text-muted">
Press play to take over the session.
</div>
)}
</div>
)}
</div>
);
}
-169
View File
@@ -1,169 +0,0 @@
import { X, Play } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import type { AlbumWithTracks, ArtistWithAlbums } from '../types';
import { albumService } from '../services/albumService';
import { artistService } from '../services/artistService';
import { Artwork } from './Artwork';
import { Button } from './ethos/Button';
import { TrackRow } from './TrackRow';
import { usePlaybackStore } from '../store/usePlaybackStore';
type InspectorMode = 'album' | 'artist' | 'track';
interface InspectorProps {
mode: InspectorMode;
id: string;
onClose: () => void;
}
function AlbumInspector({ id, onClose }: { id: string; onClose: () => void }) {
const { setQueue, playTrack } = usePlaybackStore();
const { data, isLoading } = useQuery<AlbumWithTracks>({
queryKey: ['album', id],
queryFn: () => albumService.getAlbum(id),
});
if (isLoading) {
return (
<div className="p-4 space-y-3">
<div className="skeleton h-40 w-full rounded" />
<div className="skeleton h-4 w-2/3" />
<div className="skeleton h-3 w-1/3" />
</div>
);
}
if (!data) return null;
const tracks = data.tracks ?? [];
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Album</span>
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
<X size={14} />
</button>
</div>
<div className="overflow-y-auto flex-1">
{/* Artwork + meta */}
<div className="p-4 space-y-3">
<div className="w-full aspect-square rounded-md overflow-hidden">
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="md" eager />
</div>
<div>
<h2 className="text-sm font-semibold text-text truncate">{data.title}</h2>
<p className="text-xs text-secondary">{data.artist_name || 'Unknown artist'}{data.year ? ` · ${data.year}` : ''}</p>
<p className="text-xs text-muted mt-0.5">{tracks.length} tracks</p>
</div>
<Button
variant="primary"
size="sm"
icon={<Play size={14} fill="currentColor" />}
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
disabled={!tracks.length}
className="w-full"
>
Play album
</Button>
</div>
{/* Track list */}
<div className="border-t border-border">
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Tracks</div>
<div className="space-y-0.5 px-2 pb-3">
{tracks.map((t, i) => (
<TrackRow key={t.id} track={t} queue={tracks} index={i} showActions={false} />
))}
</div>
</div>
</div>
</div>
);
}
function ArtistInspector({ id, onClose }: { id: string; onClose: () => void }) {
const { data, isLoading } = useQuery<ArtistWithAlbums>({
queryKey: ['artist', id],
queryFn: () => artistService.getArtist(id),
});
if (isLoading) {
return (
<div className="p-4 space-y-3">
<div className="skeleton h-32 w-32 rounded-full mx-auto" />
<div className="skeleton h-4 w-1/2 mx-auto" />
</div>
);
}
if (!data) return null;
const albums = data.albums ?? [];
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider text-muted">Artist</span>
<button onClick={onClose} className="text-muted hover:text-text p-0.5 rounded">
<X size={14} />
</button>
</div>
<div className="overflow-y-auto flex-1">
<div className="p-4 space-y-3 text-center">
<div className="w-24 h-24 rounded-full overflow-hidden mx-auto ring-2 ring-border">
<Artwork seed={data.name} src={data.image_path} className="w-full h-full" rounded="full" eager />
</div>
<div>
<h2 className="text-sm font-semibold text-text">{data.name}</h2>
<p className="text-xs text-muted">{albums.length} albums</p>
</div>
</div>
{albums.length > 0 && (
<div className="border-t border-border">
<div className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted">Albums</div>
<div className="grid grid-cols-3 gap-2 p-2">
{albums.map((album) => (
<Link
key={album.id}
to="/albums/$albumId"
params={{ albumId: album.id }}
className="flex flex-col gap-1 rounded-md p-1.5 hover:bg-surface0 transition-colors"
>
<div className="aspect-square rounded-sm overflow-hidden">
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full" />
</div>
<span className="text-xs text-text truncate">{album.title}</span>
</Link>
))}
</div>
</div>
)}
</div>
</div>
);
}
export type { InspectorMode };
/**
* Inspector panel — right-side detail view for albums, artists, and tracks.
* Replaces full-page navigations with a slide-in panel per Ethos conventions.
*/
export function Inspector({ mode, id, onClose }: InspectorProps) {
return (
<aside className="w-80 flex flex-col border-l border-border bg-bg1 overflow-hidden shrink-0 animate-slide-in">
{mode === 'album' && <AlbumInspector id={id} onClose={onClose} />}
{mode === 'artist' && <ArtistInspector id={id} onClose={onClose} />}
{mode === 'track' && (
<div className="p-4 text-sm text-muted text-center py-10">
Track inspector coming soon
</div>
)}
</aside>
);
}
+79 -5
View File
@@ -1,10 +1,11 @@
import { Link } from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import {
Home, Music, Disc3, Users, Tag, Compass,
Terminal, ShieldAlert,
Zap,
Settings, Sparkles,
Settings, Sparkles, X,
} from 'lucide-react';
interface NavItem {
@@ -34,7 +35,7 @@ const NAV_GROUPS: NavGroup[] = [
label: 'AI',
items: [
{ to: '/vibe', icon: Zap, label: 'Vibe' },
{ to: '/discover', icon: Compass, label: 'Discover' },
{ to: '/recommendations', icon: Compass, label: 'Found' },
],
},
{
@@ -59,15 +60,86 @@ const active =
'bg-accent/10 text-accent font-medium ' +
"before:content-[''] before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:rounded-full before:bg-accent";
export function NavRail() {
interface NavRailProps {
open: boolean;
onClose: () => void;
}
export function NavRail({ open, onClose }: NavRailProps) {
const [desktop, setDesktop] = useState(false);
const drawerRef = useRef<HTMLElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const query = window.matchMedia('(min-width: 1024px)');
const update = () => setDesktop(query.matches);
update();
query.addEventListener('change', update);
return () => query.removeEventListener('change', update);
}, []);
useEffect(() => {
if (open && !desktop) closeRef.current?.focus();
}, [open, desktop]);
useEffect(() => {
if (!open || desktop || !drawerRef.current) return;
const previous = document.activeElement as HTMLElement | null;
const trap = (event: KeyboardEvent) => {
if (event.key !== 'Tab' || !drawerRef.current) return;
const focusable = [...drawerRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
)].filter((element) => !element.hasAttribute('hidden'));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault(); last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault(); first.focus();
}
};
document.addEventListener('keydown', trap);
return () => {
document.removeEventListener('keydown', trap);
previous?.focus();
};
}, [open, desktop]);
// Do not leave an off-screen mobile drawer in the tab order. The desktop
// rail remains mounted independently of the drawer state.
if (!desktop && !open) return null;
return (
<aside className="w-48 flex flex-col bg-bg1 border-r border-border shrink-0 overflow-y-auto">
<>
{open && (
<button
type="button"
className="absolute inset-0 z-30 bg-black/60 lg:hidden"
aria-label="Close navigation"
onClick={onClose}
/>
)}
<aside
ref={drawerRef}
aria-label="Main navigation"
aria-modal={!desktop || undefined}
role={desktop ? undefined : 'dialog'}
className={`absolute inset-y-0 left-0 z-40 flex w-72 max-w-[85vw] flex-col overflow-y-auto border-r border-border bg-bg1 shadow-2xl transition-transform duration-200 lg:relative lg:z-auto lg:w-48 lg:max-w-none lg:translate-x-0 lg:shadow-none ${
open ? 'translate-x-0' : '-translate-x-full'
}`}
>
{/* App branding */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-accent text-on-accent">
<Sparkles size={14} />
</span>
<span className="text-sm font-semibold text-text tracking-tight">muzick</span>
<button
ref={closeRef}
type="button"
onClick={onClose}
className="ml-auto rounded-md p-2 text-muted hover:bg-surface0 hover:text-text lg:hidden"
aria-label="Close navigation"
>
<X size={18} />
</button>
</div>
{/* Navigation */}
@@ -85,6 +157,7 @@ export function NavRail() {
activeOptions={{ exact: exact ?? false }}
activeProps={{ className: `${base} ${active}` }}
inactiveProps={{ className: `${base} ${inactive}` }}
onClick={onClose}
>
<Icon size={15} className="flex-none transition-transform group-hover:scale-110" />
<span className="truncate">{label}</span>
@@ -100,6 +173,7 @@ export function NavRail() {
<div className="px-3 py-2 text-[10px] text-disabled border-t border-border">
muzick · v0.1
</div>
</aside>
</aside>
</>
);
}
@@ -0,0 +1,27 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { NowPlayingPanel } from './NowPlayingPanel';
describe('NowPlayingPanel', () => {
it('announces itself as a modal and keeps keyboard focus inside', async () => {
usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false });
const user = userEvent.setup();
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<NowPlayingPanel onClose={() => undefined} />
</QueryClientProvider>
);
const dialog = screen.getByRole('dialog', { name: 'Now playing queue' });
const close = screen.getByRole('button', { name: 'Close now playing' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(close).toHaveFocus();
await user.tab({ shift: true });
expect(screen.getByRole('button', { name: 'Next' })).toHaveFocus();
await user.tab();
expect(close).toHaveFocus();
});
});
+46 -13
View File
@@ -1,4 +1,5 @@
import { X, Play, Pause, SkipBack, SkipForward, Disc3 } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { usePlaybackStore } from '../store/usePlaybackStore';
@@ -6,9 +7,36 @@ import { Artwork } from './Artwork';
import { ArtistLinks } from './ArtistLinks';
import { TrackRow, formatDuration } from './TrackRow';
import { albumService } from '../services/albumService';
import { useTransport } from '../hooks/useTransport';
export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
const { currentTrack, queue, isPlaying, position, duration, play, pause, next, prev, setPosition } = usePlaybackStore();
const panelRef = useRef<HTMLElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => { closeRef.current?.focus(); }, []);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
const trap = (event: KeyboardEvent) => {
if (event.key !== 'Tab' || !panelRef.current) return;
const focusable = [...panelRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
)].filter((element) => !element.hasAttribute('hidden'));
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault(); last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault(); first.focus();
}
};
document.addEventListener('keydown', trap);
return () => {
document.removeEventListener('keydown', trap);
previous?.focus();
};
}, []);
const { currentTrack, queue, position, duration } = usePlaybackStore();
const transport = useTransport();
const currentIdx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
const upNext = currentIdx >= 0 ? queue.slice(currentIdx + 1) : queue;
@@ -22,25 +50,29 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
const artwork = albumQ.data?.artwork_id ?? currentTrack?.artwork_id ?? null;
return (
<aside className="w-96 flex flex-col border-l border-border/70 bg-bg1/80 backdrop-blur-sm overflow-hidden shrink-0">
<aside ref={panelRef} role="dialog" aria-modal="true" aria-label="Now playing queue" className="absolute inset-0 z-30 flex w-full flex-col overflow-hidden border-l border-border/70 bg-bg1 backdrop-blur-sm animate-slide-in sm:left-auto sm:w-96 lg:relative lg:z-auto lg:bg-bg1/80">
<div className="flex items-center justify-between px-4 py-3 border-b border-border/70">
<span className="text-sm font-semibold text-text">Now Playing</span>
<button onClick={onClose} className="text-muted hover:text-text p-1 rounded">
<button ref={closeRef} onClick={onClose} aria-label="Close now playing" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface1">
<X size={16} />
</button>
</div>
<div className="p-4 space-y-4">
{/* The artwork below is capped against viewport height, not just width.
At full width on a phone the square alone is taller than the space
between the top bar and the transport, which pushed Up Next — the
reason the panel opens — entirely off the screen. */}
<div className="p-3 space-y-3 sm:p-4 sm:space-y-4">
{currentTrack?.album_id ? (
<Link to="/albums/$albumId" params={{ albumId: currentTrack.album_id }}
className="group block aspect-square rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album">
className="group mx-auto block aspect-square w-full max-w-[min(100%,34vh)] rounded-xl overflow-hidden relative shadow-lg shadow-black/40" title="Go to album">
<Artwork seed={`${currentTrack.title} ${currentTrack.artist}`} src={artwork} className="w-full h-full transition-transform group-hover:scale-105" rounded="xl" />
<div className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-colors">
<Disc3 size={28} className="text-on-accent opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</Link>
) : (
<div className="aspect-square rounded-xl overflow-hidden shadow-lg shadow-black/40">
<div className="mx-auto aspect-square w-full max-w-[min(100%,34vh)] rounded-xl overflow-hidden shadow-lg shadow-black/40">
<Artwork seed={currentTrack ? `${currentTrack.title} ${currentTrack.artist}` : 'empty'} src={artwork} className="w-full h-full" rounded="xl" />
</div>
)}
@@ -60,11 +92,11 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
<div className="text-center text-sm text-muted italic">No track playing</div>
)}
<div className="space-y-1">
<div className="track-list">
<input
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
value={Math.min(position, duration || 0)}
onChange={(e) => setPosition(Number(e.target.value))}
onChange={(e) => transport.seek(Number(e.target.value))}
disabled={!currentTrack || duration <= 0}
className="w-full cursor-pointer"
/>
@@ -74,16 +106,17 @@ export function NowPlayingPanel({ onClose }: { onClose: () => void }) {
</div>
</div>
<div className="flex items-center justify-center gap-6">
<button onClick={prev} className="text-muted hover:text-text"><SkipBack size={20} /></button>
<div className="flex items-center justify-center gap-4 sm:gap-6">
<button onClick={transport.prev} aria-label="Previous" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipBack size={20} /></button>
<button
onClick={() => isPlaying ? pause() : play()}
onClick={transport.toggle}
aria-label={transport.playing ? 'Pause' : 'Play'}
disabled={!currentTrack}
className="transport-btn"
>
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
{transport.playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
<button onClick={next} className="text-muted hover:text-text"><SkipForward size={20} /></button>
<button onClick={transport.next} aria-label="Next" className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0"><SkipForward size={20} /></button>
</div>
</div>
+8 -5
View File
@@ -4,15 +4,18 @@ type ContainerWidth = 'sm' | 'md' | 'lg' | 'full';
interface PageContainerProps {
children: ReactNode;
/** Controls max-width. sm → max-w-2xl, md → max-w-3xl (default), lg → max-w-5xl, full → no constraint. */
/** Controls max-width. sm → reading width (prose, forms), md → default, lg → widest grid, full → no constraint. */
width?: ContainerWidth;
className?: string;
}
// Measured on a 1440px viewport: the old md (768px) left ~480px of the main
// area empty and pinned every grid to 45 columns. These track the content,
// not an arbitrary prose measure — only `sm` stays narrow, for forms and prose.
const WIDTH_CLASSES: Record<ContainerWidth, string> = {
sm: 'max-w-2xl',
md: 'max-w-3xl',
lg: 'max-w-5xl',
sm: 'max-w-3xl',
md: 'max-w-[1240px]',
lg: 'max-w-[1400px]',
full: '',
};
@@ -24,7 +27,7 @@ const WIDTH_CLASSES: Record<ContainerWidth, string> = {
*/
export function PageContainer({ children, width = 'md', className = '' }: PageContainerProps) {
return (
<div className={`mx-auto space-y-6 ${WIDTH_CLASSES[width]} ${className}`}>
<div className={`mx-auto space-y-4 ${WIDTH_CLASSES[width]} ${className}`}>
{children}
</div>
);
+20 -20
View File
@@ -1,35 +1,35 @@
import type { LucideIcon } from 'lucide-react';
interface PageHeaderProps {
icon?: LucideIcon;
title: string;
/** Human-written line under the title (sans). Keep it short or leave it out. */
subtitle?: string;
/** Machine values — counts, sizes, durations. Rendered mono, per Ethos law 1. */
meta?: string;
/** Optional right-aligned actions (buttons, toggles, etc.). */
actions?: React.ReactNode;
}
/**
* Consistent page heading: a gradient title with an optional accent icon chip,
* subtitle, and right-aligned action slot. Used across the library pages so
* every screen opens the same way.
* The one page heading for every screen (Ethos law 3 — shared shell). Editorial
* sans title, an optional human subtitle, and a mono `meta` line for whatever
* the machine knows: counts, page position, queue depth.
*
* Deliberately has no icon chip and no gradient fill. The nav rail and the
* breadcrumb already name the page; a glowing accent tile on every screen made
* the accent read as decoration rather than signal.
*/
export function PageHeader({ icon: Icon, title, subtitle, actions }: PageHeaderProps) {
export function PageHeader({ title, subtitle, meta, actions }: PageHeaderProps) {
return (
<div className="flex items-end justify-between gap-4 animate-rise">
<div className="flex items-center gap-4 min-w-0">
{Icon && (
<div className="flex h-12 w-12 flex-none items-center justify-center rounded-2xl bg-accent/15 text-accent ring-1 ring-accent/25 shadow-lg shadow-accent/10">
<Icon size={24} />
</div>
<div className="flex flex-wrap items-end justify-between gap-3 border-b border-border pb-3 animate-rise">
<div className="min-w-0">
<h1 className="truncate text-2xl font-semibold tracking-tight text-text sm:text-3xl">
{title}
</h1>
{subtitle && <p className="mt-1 text-sm text-secondary">{subtitle}</p>}
{meta && (
<p className="mt-1 truncate font-mono text-xs tabular-nums text-muted">{meta}</p>
)}
<div className="min-w-0">
<h1 className="text-gradient truncate text-3xl font-extrabold tracking-tight sm:text-4xl">
{title}
</h1>
{subtitle && <p className="mt-1 truncate text-sm text-muted">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex flex-none items-center gap-2">{actions}</div>}
{actions && <div className="flex flex-none flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
+2 -2
View File
@@ -6,7 +6,7 @@ interface PanelHeaderProps {
className?: string;
/**
* Title styling intent:
* - `'panel'` (default) — text-xs uppercase muted (Inspector, LyricsOverlay)
* - `'panel'` (default) — text-xs uppercase muted (side panels, LyricsOverlay)
* - `'heading'` — text-sm semibold text-text (NowPlayingPanel)
*/
intent?: 'panel' | 'heading';
@@ -19,7 +19,7 @@ const TITLE_CLASSES = {
/**
* Overlay/panel header bar — title label with an optional close button.
* Standardizes the pattern that was hand-rolled in Inspector (×2),
* Standardizes the pattern used by side panels,
* NowPlayingPanel, LyricsOverlay, CommandPalette, and more.
*/
export function PanelHeader({ title, onClose, className = '', intent = 'panel' }: PanelHeaderProps) {
+34 -23
View File
@@ -5,6 +5,8 @@ import { useDislikeTrack } from '../hooks/useDislikeTrack';
import { Artwork } from './Artwork';
import { ArtistLinks } from './ArtistLinks';
import { formatDuration } from './TrackRow';
import { DevicePicker } from './DevicePicker';
import { useTransport } from '../hooks/useTransport';
interface PlaybackBarProps {
queueOpen: boolean;
@@ -14,8 +16,9 @@ interface PlaybackBarProps {
}
export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyrics }: PlaybackBarProps) {
const { currentTrack, isPlaying, position, duration, volume, shuffle, repeat, play, pause, next, prev, setPosition, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
const { currentTrack, position, duration, volume, shuffle, repeat, setVolume, toggleShuffle, cycleRepeat } = usePlaybackStore();
const dislikeTrack = useDislikeTrack();
const transport = useTransport();
const handleDislike = () => {
if (!currentTrack) return;
@@ -23,9 +26,10 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
};
return (
<div className="glass h-20 border-t border-border/70 px-4 flex items-center gap-4 shrink-0 z-20">
<div className="glass safe-x safe-b border-t border-border/70 pt-2 shrink-0 z-20 [--safe-b-base:16px] [--safe-x-base:24px] sm:h-20 sm:pt-0 sm:[--safe-b-base:0px] sm:[--safe-x-base:32px]">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:flex-nowrap sm:gap-4">
{/* Track info */}
<div className="flex items-center gap-3 w-64 min-w-0 shrink-0">
<div className="flex min-w-0 flex-1 items-center gap-2.5 sm:w-64 sm:flex-none sm:gap-3">
{currentTrack ? (
<>
{currentTrack.album_id ? (
@@ -52,78 +56,84 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
<button
onClick={handleDislike}
title="Dislike (sends to quarantine)"
className="flex-none rounded-md p-1.5 text-muted hover:bg-surface1 hover:text-red-400 transition-colors"
className="flex-none rounded-md p-2 text-muted hover:bg-surface1 hover:text-red-400 transition-colors"
aria-label="Dislike — move to quarantine"
>
<ThumbsDown size={16} />
</button>
</>
) : (
<div className="text-sm text-muted italic">Nothing playing</div>
<div className="flex items-center gap-2.5">
<div className="h-12 w-12 flex-none rounded-lg border border-border bg-bg2" aria-hidden />
{/* Reached only before anything has ever played in this browser —
a returning tab restores its last track instead. */}
<div className="text-sm text-muted">Pick a track to start</div>
</div>
)}
</div>
{/* Controls + scrubber */}
<div className="flex-1 flex flex-col items-center gap-1">
<div className="flex items-center gap-3">
<div className="order-3 flex basis-full flex-col items-center gap-1 sm:order-none sm:flex-1 sm:basis-auto">
<div className="flex items-center gap-2 sm:gap-3">
<button
onClick={toggleShuffle}
className={`p-1.5 rounded-md transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`}
className={`rounded-md p-2 transition-colors ${shuffle ? 'text-accent' : 'text-muted hover:text-text'}`}
aria-label={shuffle ? 'Disable shuffle' : 'Enable shuffle'}
title={shuffle ? 'Shuffle on' : 'Shuffle off'}
>
<Shuffle size={18} />
</button>
<button onClick={prev} className="text-muted hover:text-text" aria-label="Previous">
<button onClick={transport.prev} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Previous">
<SkipBack size={20} />
</button>
<button
onClick={() => isPlaying ? pause() : play()}
onClick={transport.toggle}
disabled={!currentTrack}
className="transport-btn"
aria-label={isPlaying ? 'Pause' : 'Play'}
aria-label={transport.playing ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
{transport.playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
</button>
<button onClick={next} className="text-muted hover:text-text" aria-label="Next">
<button onClick={transport.next} className="rounded-md p-2 text-muted hover:text-text hover:bg-surface0" aria-label="Next">
<SkipForward size={20} />
</button>
<button
onClick={cycleRepeat}
className={`p-1.5 rounded-md transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`}
className={`rounded-md p-2 transition-colors ${repeat !== 'none' ? 'text-accent' : 'text-muted hover:text-text'}`}
aria-label={`Repeat: ${repeat}`}
title={repeat === 'none' ? 'Repeat off' : repeat === 'all' ? 'Repeat all' : 'Repeat one'}
>
{repeat === 'one' ? <Repeat1 size={18} /> : <Repeat size={18} />}
</button>
</div>
<div className="flex w-full max-w-lg items-center gap-2">
<span className="text-xs text-muted w-9 text-right tabular-nums">{formatDuration(position)}</span>
<div className="flex w-full max-w-xl items-center gap-2">
<span className="w-10 flex-none text-right font-mono text-xs tabular-nums text-machine">{formatDuration(position)}</span>
<input
type="range" min={0} max={Math.max(duration, 0.1)} step={0.1}
value={Math.min(position, duration || 0)}
onChange={(e) => setPosition(Number(e.target.value))}
onChange={(e) => transport.seek(Number(e.target.value))}
disabled={!currentTrack || duration <= 0}
className="flex-1 h-1 cursor-pointer"
aria-label="Seek"
/>
<span className="text-xs text-muted w-9 tabular-nums">{formatDuration(duration)}</span>
<span className="w-10 flex-none font-mono text-xs tabular-nums text-machine">{formatDuration(duration)}</span>
</div>
</div>
{/* Volume + panel toggle */}
<div className="flex items-center gap-3 w-48 justify-end shrink-0">
<Volume2 size={18} className="text-muted flex-none" />
<div className="order-2 flex items-center gap-1 justify-end shrink-0 sm:order-none sm:w-48 sm:gap-3">
<Volume2 size={18} className="hidden text-muted flex-none sm:block" />
<input
type="range" min={0} max={1} step={0.01} value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
className="w-20 h-1 cursor-pointer"
className="hidden w-20 h-1 cursor-pointer sm:block"
aria-label="Volume"
/>
<DevicePicker />
<button
onClick={onToggleLyrics}
disabled={!currentTrack}
className={`p-2 rounded-md transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
className={`rounded-md p-2 transition-colors disabled:opacity-30 ${lyricsOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text hover:bg-surface0'}`}
aria-label="Toggle lyrics"
title="Lyrics"
>
@@ -131,13 +141,14 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
</button>
<button
onClick={onToggleQueue}
className={`p-2 rounded-md transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text'}`}
className={`rounded-md p-2 transition-colors ${queueOpen ? 'bg-accent/20 text-accent' : 'text-muted hover:text-text hover:bg-surface0'}`}
aria-label="Toggle queue panel"
title="Up Next"
>
<ListMusic size={18} />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { createContext, useContext, type ReactNode } from 'react';
import { usePlaybackSync, type PlaybackSyncApi } from '../hooks/usePlaybackSync';
/**
* One sync session per app, shared by everything that draws a transport
* control. Mounting the hook twice would open two streams and register the same
* browser as two devices.
*/
export const PlaybackSyncContext = createContext<PlaybackSyncApi | null>(null);
export function PlaybackSyncProvider({ children }: { children: ReactNode }) {
const sync = usePlaybackSync();
return <PlaybackSyncContext.Provider value={sync}>{children}</PlaybackSyncContext.Provider>;
}
export function usePlaybackSyncContext(): PlaybackSyncApi {
const value = useContext(PlaybackSyncContext);
if (!value) throw new Error('usePlaybackSyncContext must be used inside PlaybackSyncProvider');
return value;
}
/**
* The same session, or nothing when the caller sits outside the provider — a
* test rendering one control, for instance. Callers that only need to know
* whether the audio is elsewhere use this and treat absence as "play here".
*/
export function useOptionalPlaybackSync(): PlaybackSyncApi | null {
return useContext(PlaybackSyncContext);
}
+24 -10
View File
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import { Search, X, Command, ChevronRight, Wifi, WifiOff } from 'lucide-react';
import { Search, X, Command, ChevronRight, Wifi, WifiOff, Menu } from 'lucide-react';
import { useNavigate, useRouterState } from '@tanstack/react-router';
import { useQuery } from '@tanstack/react-query';
import { fetchHealthStatus } from '../services/healthService';
interface TopBarProps {
onToggleCommandPalette: () => void;
onToggleNavigation: () => void;
navigationOpen: boolean;
}
/** Page title map for breadcrumbs */
@@ -15,8 +17,8 @@ const PAGE_TITLES: Record<string, string> = {
'/albums': 'Albums',
'/artists': 'Artists',
'/genres': 'Genres',
'/recommendations': 'Found',
'/vibe': 'Vibe',
'/discover': 'Discover',
'/search': 'Search',
'/settings': 'Settings',
'/quarantine': 'Quarantine',
@@ -86,7 +88,7 @@ function ConnectionStatus() {
);
}
export function TopBar({ onToggleCommandPalette }: TopBarProps) {
export function TopBar({ onToggleCommandPalette, onToggleNavigation, navigationOpen }: TopBarProps) {
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const { pathname, urlQuery } = useRouterState({
@@ -136,14 +138,23 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
};
return (
<header className="glass h-12 border-b border-border flex items-center px-3 gap-3 shrink-0 z-20">
<header className="glass h-12 border-b border-border flex items-center px-2.5 gap-2 sm:px-3 sm:gap-3 shrink-0 z-20">
<button
type="button"
onClick={onToggleNavigation}
className="rounded-md p-2 text-muted hover:bg-surface0 hover:text-text lg:hidden"
aria-label={navigationOpen ? 'Close navigation' : 'Open navigation'}
aria-expanded={navigationOpen}
>
{navigationOpen ? <X size={18} /> : <Menu size={18} />}
</button>
{/* Breadcrumbs */}
<div className="flex items-center min-w-0 flex-none max-w-[200px]">
<div className="hidden sm:flex items-center min-w-0 flex-none max-w-[200px]">
<Breadcrumbs pathname={pathname} />
</div>
{/* Universal search */}
<form onSubmit={handleSubmit} className="flex-1 max-w-md">
<form onSubmit={handleSubmit} className="min-w-0 flex-1 max-w-md">
<div className="relative group">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none transition-colors group-focus-within:text-accent" />
<input
@@ -152,7 +163,10 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search…"
className="w-full bg-surface0/70 border border-border rounded-md pl-8 pr-8 py-1.5 text-xs text-text placeholder:text-muted outline-none focus:border-accent focus:bg-surface0 transition-all"
/* bg-bg2, not bg-surface0/70: Tailwind cannot alpha-modify these
var() colors, so that class emitted nothing and the field fell
back to the UA's white — a white pill in a warm dark room. */
className="w-full bg-bg2 border border-border rounded-md pl-8 pr-8 py-1.5 text-xs text-text placeholder:text-muted outline-none focus:border-accent focus:bg-surface1 transition-colors"
/>
{q ? (
<button
@@ -164,7 +178,7 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
<X size={12} />
</button>
) : (
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex items-center rounded border border-border bg-bg2 px-1 py-0.5 text-[10px] font-medium text-muted pointer-events-none">
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:flex items-center rounded border border-border bg-surface1 px-1 py-0.5 text-[10px] font-medium text-muted pointer-events-none">
/
</kbd>
)}
@@ -172,7 +186,7 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
</form>
{/* Right section */}
<div className="flex items-center gap-2 flex-none">
<div className="flex items-center gap-1.5 sm:gap-2 flex-none">
{/* Connection status */}
<ConnectionStatus />
@@ -184,7 +198,7 @@ export function TopBar({ onToggleCommandPalette }: TopBarProps) {
>
<Command size={12} />
<span className="hidden sm:inline">Commands</span>
<kbd className="rounded border border-border bg-bg2 px-1 text-[10px] text-muted">
<kbd className="hidden md:inline rounded border border-border bg-bg2 px-1 text-[10px] text-muted">
Ctrl+K
</kbd>
</button>
+70 -33
View File
@@ -1,9 +1,9 @@
import { Play, Pause, ThumbsDown, Disc3, Sparkles } from 'lucide-react';
import { Play, Pause, ThumbsDown, Sparkles } from 'lucide-react';
import { Link, useRouter } from '@tanstack/react-router';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useDislikeTrack } from '../hooks/useDislikeTrack';
import { vibeService } from '../services/vibeService';
import { startVibeSession } from '../services/vibeSession';
import { Artwork } from './Artwork';
import { ArtistLinks } from './ArtistLinks';
@@ -24,9 +24,13 @@ interface TrackRowProps {
variant?: TrackRowVariant;
/** Show a "Vibe by track" button that starts a vibe session seeded from this track. */
showVibe?: boolean;
/** Override ordinary queue playback, for contextual actions such as Vibe seed rows. */
onSelect?: (track: Track) => void;
/** Display-only rows keep their surrounding playback controller authoritative. */
playable?: boolean;
}
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false }: TrackRowProps) {
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect, playable = true }: TrackRowProps) {
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
const dislikeTrack = useDislikeTrack();
const router = useRouter();
@@ -34,6 +38,11 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
const compact = variant === 'compact';
const handlePlay = () => {
if (!playable) return;
if (onSelect) {
onSelect(track);
return;
}
if (isCurrent) { isPlaying ? pause() : play(); return; }
// Queue the whole list and start at this track, so Previous can walk back
// into the tracks before it.
@@ -41,6 +50,12 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
playTrack(track);
};
const playLabel = !playable
? `${track.title || 'Track'} is queued by Vibe`
: isCurrent && isPlaying
? `Pause ${track.title || 'track'}`
: `Play ${track.title || 'track'}`;
const handleDislike = (e: React.MouseEvent) => {
e.stopPropagation();
dislikeTrack(track.id);
@@ -48,8 +63,8 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
const handleVibe = (e: React.MouseEvent) => {
e.stopPropagation();
// Start a vibe session then navigate to the vibe page.
vibeService.start(track.id).then(() => {
// Start and hydrate the V2 plan before showing the Vibe page.
startVibeSession(track).then(() => {
router.navigate({ to: '/vibe' });
}).catch(() => {
// Session failed — still navigate so the user can try manually.
@@ -59,32 +74,64 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
return (
<div
onClick={handlePlay}
className={`group flex w-full cursor-pointer items-center gap-3 rounded-lg border transition-colors ${
compact ? 'p-2' : 'p-2.5'
// With the title linking to its album, a seed row would otherwise only be
// selectable by its 36px artwork tile. The whole row takes the click.
onClick={onSelect ? handlePlay : undefined}
role={onSelect ? 'button' : undefined}
className={`group relative flex w-full items-center gap-2.5 rounded-md px-1.5 transition-colors ${onSelect ? 'cursor-pointer' : ''} ${
compact ? 'h-11' : 'h-[52px]'
} ${
isCurrent
? 'border-accent/60 bg-accent/10'
: 'border-border/70 bg-surface0/50 hover:border-accent/30 hover:bg-surface1'
? "bg-accent/10 before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-0.5 before:rounded-full before:bg-accent before:content-['']"
// `.track-row` carries the hover light (see index.css) — a gradient that
// falls off to the right instead of a flat slab. Display-only rows still
// light up; what they skip is the play-icon ramp below.
: 'track-row'
}`}
>
{/* Artwork + play overlay */}
<div className={`relative flex flex-none items-center justify-center rounded overflow-hidden ${
compact ? 'h-9 w-9' : 'h-10 w-10'
<button
type="button"
onClick={handlePlay}
disabled={!playable}
aria-label={playLabel}
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 disabled:cursor-default disabled:opacity-70 ${
// 32px, written as an arbitrary value on purpose: the spacing scale is
// remapped, so `h-8` is 64px and overflowed this 44px row — that overflow
// was the "stacked" look, not a design choice.
compact ? 'h-[32px] w-[32px]' : 'h-9 w-9'
}`}>
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
{/* glyph={false}: the play/pause icon below is the only glyph this tile gets. */}
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} glyph={false} className="absolute inset-0 w-full h-full" />
{isCurrent && isPlaying ? (
<Pause size={compact ? 14 : 18} className="absolute z-20 text-text opacity-100" />
) : (
<Play size={compact ? 14 : 18} className="absolute z-20 text-text opacity-0 group-hover:opacity-100" />
<Play
size={compact ? 14 : 18}
className={`absolute z-20 text-text opacity-70 transition-opacity ${playable ? 'group-hover:opacity-100' : ''}`}
/>
)}
</div>
</button>
{/* Title + artist */}
<div className="min-w-0 flex-1">
<div className={`truncate font-medium ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}>
{track.title || 'Untitled'}
</div>
{/* The title navigates to the album, matching the artist links beside it.
Playback lives on the artwork tile; a title that played was the surprise. */}
{track.album_id ? (
<Link
to="/albums/$albumId"
params={{ albumId: track.album_id }}
onClick={(e) => e.stopPropagation()}
title={track.title || 'Untitled'}
className={`block max-w-full truncate rounded text-left text-sm font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'}`}
>
{track.title || 'Untitled'}
</Link>
) : (
<span className={`block max-w-full truncate text-sm font-medium ${isCurrent ? 'text-accent' : 'text-text'}`}>
{track.title || 'Untitled'}
</span>
)}
<ArtistLinks
artists={track.artists}
fallback={track.artist}
@@ -95,24 +142,14 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
{/* Actions (vibe → album link → dislike) */}
{showActions && !compact && (
<div className="flex flex-none items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<div className="track-row-actions flex flex-none items-center gap-1">
{showVibe && (
<button onClick={handleVibe} title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent">
<button type="button" onClick={handleVibe} aria-label="Start a Vibe from this track" title="Vibe by track" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-accent">
<Sparkles size={16} />
</button>
)}
{track.album_id && (
<Link
to="/albums/$albumId"
params={{ albumId: track.album_id }}
onClick={(e) => e.stopPropagation()}
title="Go to album"
className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-text"
>
<Disc3 size={16} />
</Link>
)}
<button onClick={handleDislike} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
{/* The album disc button is gone — the title itself is the album link now. */}
<button type="button" onClick={handleDislike} aria-label={`Dislike ${track.title || 'track'}`} title="Dislike" className="rounded p-1.5 text-muted hover:bg-surface1 hover:text-red-400">
<ThumbsDown size={16} />
</button>
</div>
@@ -120,7 +157,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
{/* Duration (hidden in compact) */}
{!compact && (
<div className="flex-none text-xs tabular-nums text-muted">{formatDuration(track.duration)}</div>
<div className="flex-none pr-1 font-mono text-xs tabular-nums text-machine">{formatDuration(track.duration)}</div>
)}
</div>
);
+41
View File
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { VibeAura } from './VibeAura';
function stubMatchMedia(matches: boolean) {
vi.stubGlobal('matchMedia', (query: string) => ({
matches,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}));
}
describe('VibeAura', () => {
afterEach(() => vi.unstubAllGlobals());
it('leaves the displacement filter off a narrow viewport', () => {
stubMatchMedia(false);
const { container } = render(<VibeAura profile={{ energy: 0.8 }} ambient />);
expect(container.querySelector('filter#vibe-plasma')).toBeNull();
expect(container.querySelector('.vibe-aura-stack-plasma')).toBeNull();
// The layers themselves stay: a phone gets the churn, not the noise pass.
expect(container.querySelectorAll('.vibe-aura-layer')).toHaveLength(3);
});
it('mounts the filter and names it on the stack once there is room for it', () => {
stubMatchMedia(true);
const { container } = render(<VibeAura profile={{ energy: 0.8 }} ambient />);
expect(container.querySelector('filter#vibe-plasma')).not.toBeNull();
expect(container.querySelector('.vibe-aura-stack-plasma')).not.toBeNull();
});
it('turns the live recommendation profile into an accessible ambient state', () => {
render(<VibeAura profile={{ energy: 0.82, noveltyHunger: 0.78 }} />);
expect(screen.getByRole('img', { name: 'Current Vibe: charged energy and adventurous discovery' })).toBeInTheDocument();
expect(screen.getByText('Your Vibe is charged')).toBeInTheDocument();
});
});
+127
View File
@@ -0,0 +1,127 @@
import type { CSSProperties } from 'react';
import { useMediaQuery } from '../hooks/useMediaQuery';
export interface VibeProfile {
energy?: number;
noveltyHunger?: number;
explorationCoefficient?: number;
discoveryRadius?: number;
sessionGoal?: { type?: string; progress?: number; target?: number };
}
const clamp = (value: number | undefined, fallback: number) =>
Math.min(1, Math.max(0, typeof value === 'number' ? value : fallback));
function describeProfile(energy: number, novelty: number) {
const energyLabel = energy < 0.36 ? 'settled' : energy > 0.68 ? 'charged' : 'flowing';
const discoveryLabel = novelty < 0.36 ? 'familiar' : novelty > 0.64 ? 'adventurous' : 'balanced';
return { energyLabel, discoveryLabel };
}
/** An ambient representation of the live recommendation profile. */
export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; ambient?: boolean }) {
// The displacement pass is desktop-only; see the comment in the ambient
// branch below. Same breakpoint the layout already uses for this element.
const plasma = useMediaQuery('(min-width: 640px)');
const energy = clamp(profile.energy, 0.5);
const novelty = clamp(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3);
const { energyLabel, discoveryLabel } = describeProfile(energy, novelty);
// Hue is discovery alone: ember red when the Vibe stays familiar, gold when it
// reaches. Energy is deliberately absent — it already drives four motion
// channels below, and mixing it in here cancelled half the novelty swing.
// Warm range only; the old 205 base put a teal-blue glow in a warm brown room.
const hue = Math.round(15 + novelty * 40);
const style = {
'--vibe-hue': String(hue),
'--vibe-pulse': `${(4.8 - energy * 2.3).toFixed(2)}s`,
'--vibe-orbit': `${(11 - energy * 4).toFixed(2)}s`,
'--vibe-scale': String((0.9 + energy * 0.16).toFixed(2)),
'--vibe-core-opacity': ambient ? '0.38' : '1',
} as CSSProperties;
const aura = (
<div className="grid h-16 w-16 place-items-center rounded-full">
<div className="vibe-aura-halo" />
<div className="vibe-aura-orbit vibe-aura-orbit-one" />
<div className="vibe-aura-orbit vibe-aura-orbit-two" />
<div className="vibe-aura-core" style={{ opacity: 'var(--vibe-core-opacity, 1)' }}>
<span className="vibe-aura-spark vibe-aura-spark-one" />
<span className="vibe-aura-spark vibe-aura-spark-two" />
<span className="vibe-aura-heart" />
</div>
{!ambient && (
<div className="pointer-events-none absolute right-0 top-[calc(100%+0.4rem)] z-10 w-44 rounded-md border border-border bg-surface1 px-3 py-2 text-xs leading-relaxed text-muted opacity-0 shadow-xl transition-opacity group-hover:opacity-100 group-focus:opacity-100">
<span className="block font-medium text-text">Your Vibe is {energyLabel}</span>
<span>Leaning {discoveryLabel}; it shifts as you listen.</span>
</div>
)}
</div>
);
if (ambient) {
// Displacing clean gradients by animated fractal noise is what separates a
// plasma from a blurred blob. baseFrequency is animated in SMIL rather than
// CSS because no CSS property reaches inside an SVG filter primitive.
//
// That pass is also why the Vibe page crawled on a phone. An SVG filter is
// rasterised on the CPU, and animating baseFrequency regenerates the whole
// turbulence field every frame — for a 420x380 element, under a blur, over
// three counter-rotating layers. Below the breakpoint the filter is not
// mounted at all and the same layers churn behind a plain blur, which is
// the difference between a heavy effect and a scrolling page.
return (
<div
className="vibe-aura-blob pointer-events-none absolute z-0 h-[380px] w-[420px] -translate-x-1/2 -translate-y-1/2 opacity-85 mix-blend-screen sm:h-[560px] sm:w-[660px] sm:opacity-95"
style={style}
role="img"
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
>
{plasma && (
<svg aria-hidden className="absolute h-0 w-0">
<filter id="vibe-plasma" x="-30%" y="-30%" width="160%" height="160%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.009 0.014"
numOctaves={3}
seed={7}
result="noise"
>
<animate
attributeName="baseFrequency"
dur={`${(18 - energy * 8).toFixed(1)}s`}
values="0.009 0.014; 0.021 0.007; 0.009 0.014"
repeatCount="indefinite"
/>
</feTurbulence>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale={String(Math.round(46 + energy * 70))}
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</svg>
)}
<div className={plasma ? 'vibe-aura-stack vibe-aura-stack-plasma' : 'vibe-aura-stack'}>
<div className="vibe-aura-layer vibe-aura-rays-layer" />
<div className="vibe-aura-layer vibe-aura-swirl-layer" />
<div className="vibe-aura-layer vibe-aura-core-layer" />
</div>
</div>
);
}
return (
<div
className="group relative grid h-16 w-16 shrink-0 place-items-center rounded-full focus:outline-none"
style={style}
role="img"
tabIndex={0}
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
title={`Current Vibe: ${energyLabel} energy, ${discoveryLabel} discovery`}
>
{aura}
</div>
);
}
@@ -0,0 +1,54 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
RouterProvider,
createMemoryHistory,
createRootRoute,
createRouter,
} from '@tanstack/react-router';
import type { ReactNode } from 'react';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { VibeTimeline } from './VibeTimeline';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album',
duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
});
/** Track titles link to their album, so rows need a router in scope. */
function renderInRouter(ui: ReactNode) {
const rootRoute = createRootRoute({ component: () => ui });
const router = createRouter({ routeTree: rootRoute, history: createMemoryHistory() });
return render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<RouterProvider router={router as never} />
</QueryClientProvider>
);
}
describe('VibeTimeline', () => {
beforeEach(() => {
const current = track('current');
usePlaybackStore.setState({
currentTrack: current, queue: [current, track('upcoming')], currentIndex: 0,
isPlaying: true, queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
});
});
it('renders upcoming plan entries as display-only so they cannot hand queue ownership to ordinary playback', async () => {
const user = userEvent.setup();
renderInRouter(<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />);
const queued = await screen.findAllByRole('button', { name: 'upcoming is queued by Vibe' });
expect(queued).toHaveLength(1);
expect(queued[0]).toBeDisabled();
await user.click(queued[0]);
expect(usePlaybackStore.getState()).toMatchObject({
queueOwner: 'vibe', currentTrack: track('current'),
});
});
});
+9 -6
View File
@@ -11,10 +11,10 @@ interface VibeTimelineProps {
export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-text">
<Radio size={18} className="text-accent" />
<h2 className="text-lg font-semibold">Incoming recommendations</h2>
<span className="text-xs text-muted">({upcoming.length} buffered)</span>
<div className="flex items-center gap-2 border-b border-border pb-2">
<Radio size={14} className="text-accent" />
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted">Up next</h2>
<span className="ml-auto font-mono text-xs tabular-nums text-machine">{upcoming.length} buffered</span>
</div>
{currentTrack && (
@@ -26,7 +26,9 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
showActions={false}
variant="compact"
/>
<Badge color="accent" className="absolute right-2 top-1/2 -translate-y-1/2">Now playing</Badge>
{/* Hidden under 640px: the badge sat on top of the title. The accent
title and the accent edge already mark the current row. */}
<Badge color="accent" className="absolute right-2 top-1/2 hidden -translate-y-1/2 sm:block">Now playing</Badge>
</div>
)}
@@ -35,7 +37,7 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
No upcoming tracks buffered yet.
</div>
) : (
<div className="space-y-0.5">
<div className="track-list">
{upcoming.map((track, i) => (
<TrackRow
key={`${track.id}-${i}`}
@@ -44,6 +46,7 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
index={0}
showActions={false}
variant="compact"
playable={false}
/>
))}
</div>
+6 -6
View File
@@ -11,13 +11,13 @@ export function Skeleton({ className = '' }: { className?: string }) {
/** Rows matching TrackRow height */
export function SkeletonRows({ count = 5 }: { count?: number }) {
return (
<div className="space-y-1">
<div>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-md px-3 py-2">
<Skeleton className="h-8 w-8 flex-none rounded" />
<div key={i} className="flex h-[52px] items-center gap-2.5 rounded-md px-1.5">
<Skeleton className="h-9 w-9 flex-none rounded" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-1/3" />
<Skeleton className="h-2.5 w-1/4" />
<Skeleton className="h-3 w-1/4" />
<Skeleton className="h-2.5 w-1/6" />
</div>
<Skeleton className="h-3 w-8 flex-none" />
</div>
@@ -29,7 +29,7 @@ export function SkeletonRows({ count = 5 }: { count?: number }) {
/** Grid matching album/artist card layout */
export function SkeletonGrid({ count = 10 }: { count?: number }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex flex-col gap-2 rounded-md border border-border bg-surface0 p-2">
<Skeleton className="aspect-square rounded" />
+107
View File
@@ -0,0 +1,107 @@
import { useEffect } from 'react';
import { useToastStore, toast } from '../store/useToastStore';
/**
* Chrome fires `beforeinstallprompt` when the app qualifies for installation.
* Not in lib.dom yet, so the shape is declared here.
*/
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
const SNOOZE_KEY = 'muzick.install-prompt.snoozed-at';
const SNOOZE_MS = 30 * 24 * 60 * 60 * 1000;
// Long enough that the suggestion lands after the app has proved useful, not
// while the first page is still painting.
const DELAY_MS = 20_000;
function snoozed(): boolean {
try {
const at = Number(localStorage.getItem(SNOOZE_KEY));
return Number.isFinite(at) && at > 0 && Date.now() - at < SNOOZE_MS;
} catch {
return false; // Private mode or blocked storage — just ask.
}
}
function snooze() {
try {
localStorage.setItem(SNOOZE_KEY, String(Date.now()));
} catch {
/* ignore */
}
}
/**
* Offers "Add to Home screen" as a toast, once the browser says the app is
* installable. Declining snoozes the suggestion for a month; installing
* silences it for good.
*/
export function useInstallPrompt() {
useEffect(() => {
// Already installed — the standalone display mode is the reliable signal.
if (window.matchMedia?.('(display-mode: standalone)').matches) return;
if (snoozed()) return;
let deferred: BeforeInstallPromptEvent | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
let unsubscribe: (() => void) | undefined;
const offer = () => {
if (!deferred) return;
const event = deferred;
let accepted = false;
const id = toast.info('Muzick runs better from your home screen.', {
ttl: 0,
action: {
label: 'Install',
onClick: () => {
accepted = true;
unsubscribe?.();
// A prompt can only be shown once per event; drop it either way.
deferred = null;
void event.prompt().then(() => event.userChoice).then((choice) => {
if (choice.outcome === 'dismissed') snooze();
}).catch(() => snooze());
},
},
});
// The toast's own close button calls dismiss() directly, so the only way
// to notice a decline is to watch the toast leave the store.
unsubscribe = useToastStore.subscribe((state) => {
if (accepted) return;
if (!state.toasts.some((t) => t.id === id)) {
snooze();
unsubscribe?.();
}
});
};
const onBeforeInstallPrompt = (e: Event) => {
// Suppress Chrome's own mini-infobar so only our toast asks.
e.preventDefault();
deferred = e as BeforeInstallPromptEvent;
timer = setTimeout(offer, DELAY_MS);
};
const onInstalled = () => {
deferred = null;
if (timer) clearTimeout(timer);
snooze();
};
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
window.addEventListener('appinstalled', onInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
window.removeEventListener('appinstalled', onInstalled);
if (timer) clearTimeout(timer);
unsubscribe?.();
};
}, []);
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
/**
* Track a CSS media query from React, for the cases where a media query in the
* stylesheet is not enough deciding whether to mount an element at all, not
* just how to paint it.
*
* Returns false where `matchMedia` is missing (server render, jsdom), which
* makes the cheaper branch the default everywhere it cannot be measured.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const list = window.matchMedia(query);
const update = () => setMatches(list.matches);
update();
list.addEventListener('change', update);
return () => list.removeEventListener('change', update);
}, [query]);
return matches;
}
+215
View File
@@ -0,0 +1,215 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { playbackSyncService, type PlaybackSnapshot, type PlaybackSyncEvent } from '../services/playbackSync';
import { usePlaybackSync } from './usePlaybackSync';
const THIS_DEVICE = '11111111-1111-1111-1111-111111111111';
const OTHER_DEVICE = '22222222-2222-2222-2222-222222222222';
let emit: ((event: PlaybackSyncEvent) => void) | null = null;
const { adoptVibeSession, releaseVibeDriving } = vi.hoisted(() => ({
adoptVibeSession: vi.fn(async () => true),
releaseVibeDriving: vi.fn(),
}));
vi.mock('../services/vibeSession', () => ({ adoptVibeSession, releaseVibeDriving }));
vi.mock('../services/playbackSync', () => ({
storedDeviceId: () => null,
playbackSyncService: {
register: vi.fn(async () => ({
id: THIS_DEVICE,
name: 'Test device',
lastSeenAt: new Date(0).toISOString(),
online: true,
isOwner: false,
})),
reportState: vi.fn(async () => undefined),
transfer: vi.fn(async () => undefined),
release: vi.fn(async () => undefined),
sendCommand: vi.fn(async () => undefined),
openStream: (_deviceId: string, onEvent: (event: PlaybackSyncEvent) => void) => {
emit = onEvent;
return () => { emit = null; };
},
},
}));
function snapshot(over: Partial<PlaybackSnapshot> = {}): PlaybackSnapshot {
return {
deviceId: THIS_DEVICE,
trackId: null,
vibeSessionId: null,
queue: [],
queueIndex: 0,
position: 0,
isPlaying: true,
version: 1,
updatedAt: new Date(0).toISOString(),
...over,
};
}
async function mountOwning() {
const view = renderHook(() => usePlaybackSync());
await waitFor(() => expect(emit).not.toBeNull());
// First snapshot: this device takes the session over at 30s.
await act(async () => {
emit!({ type: 'state', state: snapshot({ position: 30, version: 1 }), devices: [] });
});
await waitFor(() => expect(view.result.current.isOwner).toBe(true));
return view;
}
describe('usePlaybackSync', () => {
beforeEach(() => {
emit = null;
useVibeStore.getState().reset();
usePlaybackStore.setState({
position: 0, isPlaying: false, queue: [], currentTrack: null, audioElsewhere: false,
queueOwner: 'ordinary', vibeAdvanceHandler: null,
});
});
it('takes the reported position when it first gains the session', async () => {
await mountOwning();
expect(usePlaybackStore.getState().position).toBe(30);
});
it('ignores snapshots echoing its own stale position while it owns the audio', async () => {
await mountOwning();
// Playback has moved on locally; the server still holds the last report.
act(() => usePlaybackStore.getState().setPosition(48));
await act(async () => {
emit!({ type: 'state', state: snapshot({ position: 30, version: 2 }), devices: [] });
});
expect(usePlaybackStore.getState().position).toBe(48);
});
it('pauses and follows along once another device takes the session', async () => {
const view = await mountOwning();
act(() => usePlaybackStore.setState({ isPlaying: true, position: 48 }));
await act(async () => {
emit!({
type: 'state',
state: snapshot({ deviceId: OTHER_DEVICE, position: 55, version: 3 }),
devices: [],
});
});
expect(usePlaybackStore.getState().isPlaying).toBe(false);
expect(usePlaybackStore.getState().position).toBe(55);
expect(view.result.current.hasRemoteOwner).toBe(true);
});
it('reports the remote play state rather than its own while it watches', async () => {
const view = await mountOwning();
await act(async () => {
emit!({
type: 'state',
state: snapshot({ deviceId: OTHER_DEVICE, isPlaying: true, version: 3 }),
devices: [],
});
});
expect(view.result.current.remotePlaying).toBe(true);
expect(usePlaybackStore.getState().isPlaying).toBe(false);
expect(usePlaybackStore.getState().audioElsewhere).toBe(true);
});
it('reports the Vibe session it is driving so the next device can take it', async () => {
await mountOwning();
act(() => {
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
usePlaybackStore.getState().setCurrentTrack({ id: 'track-1' } as never);
});
await waitFor(() => expect(playbackSyncService.reportState).toHaveBeenCalledWith(
THIS_DEVICE,
expect.objectContaining({ vibeSessionId: 'session-a' }),
));
});
it('stops driving the Vibe when the audio moves away, and adopts it back', async () => {
const view = await mountOwning();
act(() => {
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
usePlaybackStore.getState().setCurrentTrack({ id: 'track-1' } as never);
usePlaybackStore.getState().setVibeAdvanceHandler(() => undefined);
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
});
await act(async () => {
emit!({
type: 'state',
state: snapshot({ deviceId: OTHER_DEVICE, vibeSessionId: 'session-a', version: 3 }),
devices: [],
});
});
// One driver at a time, and it is whichever device holds the audio.
expect(releaseVibeDriving).toHaveBeenCalled();
expect(adoptVibeSession).not.toHaveBeenCalled();
await act(async () => {
emit!({
type: 'state',
state: snapshot({ deviceId: THIS_DEVICE, vibeSessionId: 'session-a', version: 4 }),
devices: [],
});
});
expect(view.result.current.isOwner).toBe(true);
expect(usePlaybackStore.getState().audioElsewhere).toBe(false);
expect(adoptVibeSession).toHaveBeenCalledWith('session-a');
});
it('lets go of its own Vibe when it takes over playback that is not one', async () => {
await mountOwning();
act(() => {
usePlaybackStore.getState().setVibeQueue([{ id: 'track-1' }] as never);
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'track-1' });
});
// The other device played an album, and this one is taking that over.
await act(async () => {
emit!({
type: 'state',
state: snapshot({ deviceId: OTHER_DEVICE, vibeSessionId: null, version: 3 }),
devices: [],
});
});
await act(async () => {
emit!({ type: 'state', state: snapshot({ deviceId: THIS_DEVICE, vibeSessionId: null, version: 4 }), devices: [] });
});
expect(adoptVibeSession).not.toHaveBeenCalled();
expect(useVibeStore.getState().activeSessionId).toBeNull();
});
it('keeps playing and reclaims the session when a dropped stream leaves it unowned', async () => {
await mountOwning();
act(() => usePlaybackStore.setState({
isPlaying: true,
position: 48,
currentTrack: { id: 'track-1' } as never,
}));
// What the server publishes after this device's stream breaks: nobody owns
// the audio, and the position is this device's own report from 18s ago.
await act(async () => {
emit!({ type: 'state', state: snapshot({ deviceId: null, position: 30, version: 4 }), devices: [] });
});
expect(usePlaybackStore.getState().isPlaying).toBe(true);
expect(usePlaybackStore.getState().position).toBe(48);
expect(playbackSyncService.reportState).toHaveBeenCalled();
});
});
+317
View File
@@ -0,0 +1,317 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import {
PlaybackCommand,
PlaybackDevice,
PlaybackSnapshot,
playbackSyncService,
storedDeviceId,
} from '../services/playbackSync';
import { trackService } from '../services/trackService';
import { adoptVibeSession, releaseVibeDriving } from '../services/vibeSession';
import type { Track } from '../types';
/**
* Keeps this browser in step with the listener's other devices.
*
* Exactly one device holds the audio. That device reports what it is playing;
* every other device renders the same thing and, when the listener presses a
* control, sends a command instead of playing locally. Picking a device from
* the menu moves the audio: the new owner resumes the same track at the
* position the old one last reported, and the old one stops when it sees the
* session is no longer its.
*/
/** Position drifts constantly; anything faster than this is noise on the wire. */
const POSITION_REPORT_MS = 10_000;
/** Waits between attempts to register this device when the network is down. */
const REGISTER_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000];
export interface PlaybackSyncApi {
deviceId: string | null;
devices: PlaybackDevice[];
isOwner: boolean;
/** True once another device holds the audio, so controls become remote controls. */
hasRemoteOwner: boolean;
/**
* Whether the device holding the audio is playing. The local `isPlaying` says
* nothing about it a watching device is always paused so controls that
* draw a play/pause state read this one while `hasRemoteOwner` is true.
*/
remotePlaying: boolean;
transferTo: (deviceId: string) => Promise<void>;
sendCommand: (command: PlaybackCommand) => Promise<void>;
}
export function usePlaybackSync(): PlaybackSyncApi {
const [deviceId, setDeviceId] = useState<string | null>(storedDeviceId());
const [devices, setDevices] = useState<PlaybackDevice[]>([]);
const [ownerId, setOwnerId] = useState<string | null>(null);
const [remotePlaying, setRemotePlaying] = useState(false);
const deviceIdRef = useRef<string | null>(deviceId);
const ownerIdRef = useRef<string | null>(null);
// Set while a remote snapshot or command is being written into the store, so
// the store subscription below does not report those changes straight back.
const applyingRemote = useRef(false);
const lastVersion = useRef(-1);
const lastPositionReport = useRef(0);
deviceIdRef.current = deviceId;
ownerIdRef.current = ownerId;
const reportNow = useCallback(async () => {
const id = deviceIdRef.current;
if (!id) return;
const store = usePlaybackStore.getState();
const vibe = useVibeStore.getState();
try {
await playbackSyncService.reportState(id, {
trackId: store.currentTrack?.id ?? null,
// Reported by the device driving the Vibe, and reported as null by a
// device playing anything else, so the next owner knows which it is.
vibeSessionId: store.queueOwner === 'vibe' ? vibe.activeSessionId : null,
queue: store.queue,
queueIndex: store.currentIndex,
position: store.position,
isPlaying: store.isPlaying,
});
} catch {
// 409 means another device owns the session now. Its next state event is
// what corrects this one, so there is nothing to do here.
}
}, []);
/**
* Take the queue off the wire without ending a Vibe this device is running.
* `setQueue` is an ownership handoff: it drops the advance handler, which is
* the thing that asks the server for the next Vibe track. A snapshot from
* another device used to go through it, so mirroring the desktop or taking
* the audio back afterwards turned a live session into a static list of the
* hundred tracks that happened to be synced.
*/
const adoptQueue = useCallback((queue: Track[]) => {
if (queue.length === 0) return;
const store = usePlaybackStore.getState();
const runningVibe = store.queueOwner === 'vibe' && useVibeStore.getState().activeSessionId !== null;
if (runningVibe) store.setVibeQueue(queue);
else store.setQueue(queue);
}, []);
const applySnapshot = useCallback(async (state: PlaybackSnapshot) => {
if (state.version <= lastVersion.current) return;
lastVersion.current = state.version;
const store = usePlaybackStore.getState();
const iOwnIt = state.deviceId !== null && state.deviceId === deviceIdRef.current;
// Captured before setOwnerId, which only reaches the ref on the next render.
const alreadyOwnedIt = ownerIdRef.current !== null && ownerIdRef.current === deviceIdRef.current;
setOwnerId(state.deviceId);
// What the controls draw, and whether the engine should load anything: both
// follow from where the audio is, so they are set before any of the writes
// below and on every snapshot, including the ones the owner ignores.
const elsewhere = state.deviceId !== null && !iOwnIt;
setRemotePlaying(elsewhere ? state.isPlaying : false);
store.setAudioElsewhere(elsewhere);
// The server publishes a snapshot for anything that touches the session,
// including another device merely registering on page load. For the device
// already holding the audio those snapshots carry nothing new: the position
// in them is this device's own last report, up to POSITION_REPORT_MS old.
// Applying it would drag playback backwards, so the owner ignores them and
// stays the authority on its own position.
if (iOwnIt && alreadyOwnedIt) return;
// Nobody holds the session. That is not an instruction to stop: it is what
// the server publishes when this device's stream broke for a moment, or
// when the sweep freed an owner it thought was gone. A device with audio
// loaded claims the session back instead of pausing and rewinding to the
// position it last reported, which is how a phone losing signal for ten
// seconds used to stop playing.
if (state.deviceId === null && store.currentTrack) {
if (alreadyOwnedIt || store.isPlaying) void reportNow();
return;
}
applyingRemote.current = true;
try {
if (!iOwnIt) {
// Another device holds the audio, and with it the Vibe. Stop driving the
// session from here, then show what it plays and make no sound.
if (store.isPlaying) store.pause();
if (store.vibeAdvanceHandler) releaseVibeDriving();
adoptQueue(state.queue);
const shown = state.queue[state.queueIndex] ?? store.currentTrack;
if (shown && shown.id !== store.currentTrack?.id) store.setCurrentTrack(shown);
store.setPosition(state.position);
return;
}
// We just took the session over: rebuild the queue, land on the right
// track, and resume from where the previous device actually was.
adoptQueue(state.queue);
let track: Track | null = state.queue[state.queueIndex] ?? null;
if (!track && state.trackId && state.trackId !== store.currentTrack?.id) {
track = await trackService.getTrack(state.trackId).catch(() => null);
}
if (track && track.id !== store.currentTrack?.id) store.playTrack(track);
store.setPosition(state.position);
if (state.isPlaying) store.play();
else store.pause();
} finally {
applyingRemote.current = false;
}
// Only a device that just took the audio reaches this: every other case
// returned above. Vibe control travels with the audio, so pick up the
// session the previous owner was driving — or let go of the one this device
// still holds, because whatever is playing now is not it.
if (state.vibeSessionId) {
// Adoption rewrites the unplayed queue from the durable plan, and the
// others are showing this device's queue, so tell them.
if (await adoptVibeSession(state.vibeSessionId).catch(() => false)) void reportNow();
} else if (useVibeStore.getState().activeSessionId) {
releaseVibeDriving();
useVibeStore.getState().reset();
}
}, [adoptQueue, reportNow]);
const applyCommand = useCallback(async (command: PlaybackCommand) => {
const store = usePlaybackStore.getState();
applyingRemote.current = true;
try {
switch (command.type) {
case 'play': store.play(); break;
case 'pause': store.pause(); break;
case 'next': store.next(); break;
case 'prev': store.prev(); break;
case 'seek': store.setPosition(command.position); break;
case 'play_track': {
const queued = store.queue.find((t) => t.id === command.trackId);
const track = queued ?? await trackService.getTrack(command.trackId).catch(() => null);
if (track) store.playTrack(track);
break;
}
}
} finally {
applyingRemote.current = false;
}
// The command changed what this device plays, so the others need to know.
void reportNow();
}, [reportNow]);
// Register, then hold the stream open for as long as the app is mounted.
useEffect(() => {
let closeStream: (() => void) | null = null;
let cancelled = false;
let retry: ReturnType<typeof setTimeout> | undefined;
// Registration is one request, and a phone woken with no network fails it.
// Giving up there left that tab with no device id and no stream until the
// listener reloaded the page, so keep asking instead.
const attempt = async (attemptCount: number) => {
const device = await playbackSyncService.register().catch(() => null);
if (cancelled) return;
if (!device) {
const wait = REGISTER_BACKOFF_MS[Math.min(attemptCount, REGISTER_BACKOFF_MS.length - 1)];
retry = setTimeout(() => void attempt(attemptCount + 1), wait);
return;
}
setDeviceId(device.id);
deviceIdRef.current = device.id;
closeStream = playbackSyncService.openStream(device.id, (event) => {
if (event.type === 'state') {
setDevices(event.devices);
void applySnapshot(event.state);
} else {
void applyCommand(event.command);
}
});
};
void attempt(0);
return () => {
cancelled = true;
clearTimeout(retry);
closeStream?.();
// Nothing is watching the session any more, so nothing knows where the
// audio is. Leaving the flag set would keep the engine silent for good.
usePlaybackStore.getState().setAudioElsewhere(false);
};
}, [applySnapshot, applyCommand]);
// Local playback is reported upward — but only from the device that owns the
// audio, and only when the change did not come from the network in the first
// place. Starting playback on an unowned session claims it.
useEffect(() => {
let previous = usePlaybackStore.getState();
return usePlaybackStore.subscribe((state) => {
const prev = previous;
previous = state;
const id = deviceIdRef.current;
if (!id || applyingRemote.current) return;
const trackChanged = state.currentTrack?.id !== prev.currentTrack?.id;
const playingChanged = state.isPlaying !== prev.isPlaying;
const startedPlaying = state.isPlaying && !prev.isPlaying;
if (ownerIdRef.current !== id) {
// A device that does not own the session only takes it by starting
// playback here. Everything else it does stays local.
if (startedPlaying || (trackChanged && state.isPlaying)) {
void playbackSyncService.transfer(id).then(() => reportNow());
}
return;
}
if (trackChanged || playingChanged) {
void reportNow();
lastPositionReport.current = Date.now();
return;
}
if (Date.now() - lastPositionReport.current >= POSITION_REPORT_MS) {
lastPositionReport.current = Date.now();
void reportNow();
}
});
}, [reportNow]);
// Leaving the page hands the session back rather than stranding it on a tab
// that is gone. The stream close does this too; this covers the browsers that
// keep a closing connection alive long enough to matter.
useEffect(() => {
const release = () => {
const id = deviceIdRef.current;
if (id && ownerIdRef.current === id) void playbackSyncService.release(id);
};
window.addEventListener('pagehide', release);
return () => window.removeEventListener('pagehide', release);
}, []);
const transferTo = useCallback(async (target: string) => {
// The target resumes from the last position on record, so flush the real
// one first — otherwise handing the audio over rewinds it by up to
// POSITION_REPORT_MS.
if (ownerIdRef.current !== null && ownerIdRef.current === deviceIdRef.current) {
await reportNow();
}
await playbackSyncService.transfer(target);
}, [reportNow]);
const sendCommand = useCallback(async (command: PlaybackCommand) => {
await playbackSyncService.sendCommand(command);
}, []);
return {
deviceId,
devices,
isOwner: ownerId !== null && ownerId === deviceId,
hasRemoteOwner: ownerId !== null && ownerId !== deviceId,
remotePlaying,
transferTo,
sendCommand,
};
}
+86
View File
@@ -0,0 +1,86 @@
import { renderHook, act } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { PlaybackSyncContext } from '../components/PlaybackSyncProvider';
import type { PlaybackSyncApi } from './usePlaybackSync';
import type { PlaybackCommand } from '../services/playbackSync';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useTransport } from './useTransport';
function wrapperFor(api: Partial<PlaybackSyncApi>) {
const value = {
deviceId: 'this-device',
devices: [],
isOwner: false,
hasRemoteOwner: false,
remotePlaying: false,
transferTo: async () => undefined,
sendCommand: async () => undefined,
...api,
} as PlaybackSyncApi;
return ({ children }: { children: ReactNode }) => (
<PlaybackSyncContext.Provider value={value}>{children}</PlaybackSyncContext.Provider>
);
}
describe('useTransport', () => {
it('plays here when this device holds the audio', () => {
usePlaybackStore.setState({ isPlaying: false });
const sendCommand = vi.fn();
const { result } = renderHook(() => useTransport(), {
wrapper: wrapperFor({ isOwner: true, hasRemoteOwner: false, sendCommand }),
});
act(() => result.current.toggle());
expect(sendCommand).not.toHaveBeenCalled();
expect(usePlaybackStore.getState().isPlaying).toBe(true);
});
it('forwards the press when another device holds the audio', () => {
// A watching device keeps its own audio paused, so what the button does
// follows the remote state and never the local one.
usePlaybackStore.setState({ isPlaying: false });
const sendCommand = vi.fn(async (_command: PlaybackCommand) => undefined);
const { result } = renderHook(() => useTransport(), {
wrapper: wrapperFor({ hasRemoteOwner: true, remotePlaying: true, sendCommand }),
});
expect(result.current.playing).toBe(true);
act(() => {
result.current.toggle();
result.current.next();
result.current.seek(30);
});
expect(sendCommand.mock.calls.map(([command]) => command)).toEqual([
{ type: 'pause' },
{ type: 'next' },
{ type: 'seek', position: 30 },
]);
// The remote device is the one that stops; this one never started.
expect(usePlaybackStore.getState().isPlaying).toBe(false);
});
it('asks a paused remote device to play, whatever this device was doing', () => {
usePlaybackStore.setState({ isPlaying: true });
const sendCommand = vi.fn(async (_command: PlaybackCommand) => undefined);
const { result } = renderHook(() => useTransport(), {
wrapper: wrapperFor({ hasRemoteOwner: true, remotePlaying: false, sendCommand }),
});
act(() => result.current.toggle());
expect(sendCommand).toHaveBeenCalledWith({ type: 'play' });
});
it('plays here when there is no sync session at all', () => {
usePlaybackStore.setState({ isPlaying: true });
const { result } = renderHook(() => useTransport());
act(() => result.current.pause());
expect(usePlaybackStore.getState().isPlaying).toBe(false);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useOptionalPlaybackSync } from '../components/PlaybackSyncProvider';
/**
* The transport every control set should call.
*
* While another device holds the audio, a press has to travel to that device
* instead of starting a second stream here. Controls that reach into the
* playback store directly work on the desktop and silently do nothing useful
* from a phone, so there is one place that makes the choice.
*/
export interface Transport {
play: () => void;
pause: () => void;
toggle: () => void;
next: () => void;
prev: () => void;
seek: (seconds: number) => void;
/** True when the presses are being forwarded rather than played here. */
remote: boolean;
/**
* Whether the audio is playing, wherever it is. A watching device holds its
* own store paused, so a control that reads `isPlaying` from the store draws a
* Play button while the desktop plays and then sends `play` when the
* listener presses it, which is why pausing from a phone never worked. Every
* control reads this instead.
*/
playing: boolean;
}
export function useTransport(): Transport {
const sync = useOptionalPlaybackSync();
const hasRemoteOwner = sync?.hasRemoteOwner ?? false;
const remotePlaying = sync?.remotePlaying ?? false;
const sendCommand = sync?.sendCommand;
const localPlaying = usePlaybackStore((state) => state.isPlaying);
const playing = hasRemoteOwner ? remotePlaying : localPlaying;
return useMemo(() => {
const local = () => usePlaybackStore.getState();
const away = hasRemoteOwner && sendCommand !== undefined;
// The owning device may hold the session without listening on it — a killed
// tab keeps it until the sweep. The backend answers that with a 409, and a
// rejected press is not worth an unhandled rejection.
const send = (command: Parameters<NonNullable<typeof sendCommand>>[0]) =>
void Promise.resolve(sendCommand!(command)).catch(() => undefined);
return {
play: () => (away ? send({ type: 'play' }) : local().play()),
pause: () => (away ? send({ type: 'pause' }) : local().pause()),
toggle: () => {
if (away) send({ type: playing ? 'pause' : 'play' });
else if (playing) local().pause();
else local().play();
},
next: () => (away ? send({ type: 'next' }) : local().next()),
prev: () => (away ? send({ type: 'prev' }) : local().prev()),
seek: (seconds: number) =>
away ? send({ type: 'seek', position: seconds }) : local().setPosition(seconds),
remote: away,
playing,
};
}, [hasRemoteOwner, playing, sendCommand]);
}
+236
View File
@@ -68,6 +68,9 @@
--ethos-secondary: #B4AA98;
--ethos-muted: #756C5C;
--ethos-disabled: #5a5347;
/* Mono default (Ethos law 1) machine values sit a step above `muted` so a
count or a duration stays legible without competing with the human label. */
--ethos-machine: #9C917D;
/* muzick fingerprint: honey amber */
--ethos-accent: #EDA24E;
@@ -100,6 +103,38 @@ body {
#root {
position: relative;
z-index: 1;
min-height: 100dvh;
}
button,
a,
input[type='range'] {
-webkit-tap-highlight-color: transparent;
}
/* Safe area
Installed on Android the app paints edge to edge (viewport-fit=cover), so the
gesture bar and any display cutout sit on top of the layout. These add the
inset on top of whatever padding the element already carries. */
.safe-b {
padding-bottom: calc(var(--safe-b-base, 0px) + env(safe-area-inset-bottom));
}
.safe-x {
padding-left: calc(var(--safe-x-base, 0px) + env(safe-area-inset-left));
padding-right: calc(var(--safe-x-base, 0px) + env(safe-area-inset-right));
}
@media (pointer: coarse) {
input[type='range']::-webkit-slider-thumb {
width: 18px;
height: 18px;
margin-top: -7px;
}
input[type='range']::-moz-range-thumb {
width: 18px;
height: 18px;
}
}
/* ── Focus ring: 2px accent, for keyboard users only ──────────────────────── */
@@ -164,6 +199,154 @@ html { scroll-behavior: smooth; }
border-radius: 4px;
}
/* Vibe's profile aura: a small, old-player-inspired creature whose color and
movement come from the active recommendation state. */
@keyframes vibe-aura-pulse {
0%, 100% { transform: scale(var(--vibe-scale)) rotate(-7deg); filter: brightness(.9); }
50% { transform: scale(calc(var(--vibe-scale) * 1.12)) rotate(8deg); filter: brightness(1.28); }
}
@keyframes vibe-aura-orbit {
to { transform: rotate(360deg); }
}
@keyframes vibe-aura-spark {
0%, 100% { transform: translateY(0) scale(.75); opacity: .38; }
50% { transform: translateY(-5px) scale(1.15); opacity: 1; }
}
.vibe-aura-halo {
position: absolute;
inset: 7px;
border-radius: 9999px;
background: hsl(var(--vibe-hue) 88% 62% / .22);
filter: blur(12px);
animation: vibe-aura-pulse var(--vibe-pulse) ease-in-out infinite;
}
.vibe-aura-core {
position: relative;
width: 30px;
height: 36px;
border: 1px solid hsl(var(--vibe-hue) 95% 86% / .72);
border-radius: 46% 54% 58% 42% / 43% 45% 55% 57%;
background: radial-gradient(circle at 36% 28%, hsl(var(--vibe-hue) 100% 93%), hsl(var(--vibe-hue) 88% 61%) 38%, hsl(calc(var(--vibe-hue) + 35) 68% 31%) 100%);
box-shadow: inset 3px 2px 8px hsl(0 0% 100% / .34), 0 0 17px hsl(var(--vibe-hue) 92% 59% / .78);
animation: vibe-aura-pulse var(--vibe-pulse) ease-in-out infinite;
}
.vibe-aura-orbit {
position: absolute;
width: 47px;
height: 47px;
border: 1px solid hsl(var(--vibe-hue) 90% 76% / .36);
border-radius: 47% 53% 43% 57%;
animation: vibe-aura-orbit var(--vibe-orbit) linear infinite;
}
.vibe-aura-orbit-two {
width: 56px;
height: 37px;
border-color: hsl(calc(var(--vibe-hue) + 55) 90% 76% / .24);
animation-direction: reverse;
animation-duration: calc(var(--vibe-orbit) * 1.45);
}
.vibe-aura-heart,
.vibe-aura-spark {
position: absolute;
display: block;
border-radius: 9999px;
background: hsl(0 0% 100% / .9);
box-shadow: 0 0 8px hsl(0 0% 100% / .92);
}
.vibe-aura-heart { width: 7px; height: 7px; left: 11px; top: 15px; }
.vibe-aura-spark { width: 4px; height: 4px; animation: vibe-aura-spark 2.1s ease-in-out infinite; }
.vibe-aura-spark-one { left: -4px; top: 5px; }
.vibe-aura-spark-two { right: -3px; bottom: 7px; animation-delay: -1s; }
/* Ambient variant: a plasma in the page background, in the lineage of a media
player visualizer. Three layers a hot core, a cooler counter-rotating
companion, and a fan of rays stacked and pushed through one SVG turbulence
displacement pass (see VibeAura.tsx), which is what turns clean gradients
into churning flame. Centering lives on the wrapper, never here: these
keyframes animate `transform` and would overwrite it. */
/* Anchor lives here, not in utility classes: the offsets differ per breakpoint
and the -50% centering pair sits on the same element, so keeping both in one
place is what stops the two from fighting. Off to the right on purpose the
reading column stays clear and the plasma bleeds past the track rows. */
.vibe-aura-blob {
left: 62%;
top: 52%;
}
@media (min-width: 640px) {
.vibe-aura-blob { left: 80%; top: 56%; }
}
.vibe-aura-stack {
position: absolute;
inset: 0;
/* Blur alone is the cheap variant, and the only one phones get. The
displacement pass is added by .vibe-aura-stack-plasma, which VibeAura
applies only when it has also mounted the filter it names. */
filter: blur(14px);
}
.vibe-aura-stack-plasma {
filter: url(#vibe-plasma) blur(14px);
}
.vibe-aura-layer {
position: absolute;
inset: 0;
border-radius: 50%;
mix-blend-mode: screen;
}
@keyframes vibe-aura-churn {
0% { transform: scale(var(--vibe-scale)) translate3d(-7%, -4%, 0) rotate(0deg); }
33% { transform: scale(calc(var(--vibe-scale) * 1.22)) translate3d(6%, -8%, 0) rotate(120deg); }
66% { transform: scale(calc(var(--vibe-scale) * 0.9)) translate3d(8%, 7%, 0) rotate(240deg); }
100% { transform: scale(var(--vibe-scale)) translate3d(-7%, -4%, 0) rotate(360deg); }
}
@keyframes vibe-aura-spin {
from { transform: rotate(0deg) scale(var(--vibe-scale)); }
to { transform: rotate(-360deg) scale(var(--vibe-scale)); }
}
@keyframes vibe-aura-breathe {
from { opacity: .55; transform: scale(.86); }
to { opacity: 1; transform: scale(1.12); }
}
/* Hot core — near-white centre falling to the profile hue. */
.vibe-aura-core-layer {
background:
radial-gradient(closest-side at 47% 44%, hsl(calc(var(--vibe-hue) + 24) 100% 82% / .95), hsl(var(--vibe-hue) 100% 58% / .75) 38%, transparent 70%),
radial-gradient(closest-side at 58% 58%, hsl(calc(var(--vibe-hue) - 12) 100% 50% / .8), transparent 66%);
animation: vibe-aura-churn var(--vibe-orbit) ease-in-out infinite;
}
/* Companion — turns the other way so the two shear against each other. */
.vibe-aura-swirl-layer {
background:
radial-gradient(closest-side at 62% 40%, hsl(calc(var(--vibe-hue) - 20) 100% 54% / .7), transparent 68%),
radial-gradient(closest-side at 36% 66%, hsl(calc(var(--vibe-hue) - 44) 96% 50% / .58), transparent 72%);
animation: vibe-aura-churn calc(var(--vibe-orbit) * 1.6) ease-in-out infinite reverse;
}
/* Ray fan the spikes the displacement pass bends into filaments. Masked to a
ring so the centre stays a clean hot core. */
.vibe-aura-rays-layer {
background: repeating-conic-gradient(
from 0deg,
transparent 0deg 5deg,
hsl(calc(var(--vibe-hue) + 8) 100% 66% / .5) 5deg 7.5deg
);
-webkit-mask-image: radial-gradient(closest-side, transparent 18%, #000 46%, transparent 82%);
mask-image: radial-gradient(closest-side, transparent 18%, #000 46%, transparent 82%);
animation: vibe-aura-spin calc(var(--vibe-orbit) * 2.4) linear infinite;
}
/* Pulse at the profile's tempo. This lives on the stack, not on the outer
.vibe-aura-blob: that element carries the -translate-x-1/2 -translate-y-1/2
centering, and an animation setting `transform` overwrites it, which anchors
the plasma by its top-left corner instead of its middle. */
.vibe-aura-stack {
animation: vibe-aura-breathe var(--vibe-pulse) ease-in-out infinite alternate;
}
@media (min-width: 640px) {
.vibe-aura-stack { filter: blur(20px); }
.vibe-aura-stack-plasma { filter: url(#vibe-plasma) blur(20px); }
}
@media (prefers-reduced-motion: reduce) {
.vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark,
.vibe-aura-stack, .vibe-aura-layer { animation: none; }
}
/* ── Component base classes ────────────────────────────────────────────────── */
/* Card surface — standard container */
@@ -182,6 +365,25 @@ html { scroll-behavior: smooth; }
border-color: color-mix(in srgb, var(--ethos-accent) 40%, transparent);
}
/* Track list one dense, hairline-separated column everywhere a list of tracks
appears. Replaces the per-page `space-y-1` around bordered row cards, which
cost 90px per track and fit only 7 rows on a 900px screen. */
.track-list > * + * {
border-top: 1px solid color-mix(in srgb, var(--ethos-border) 60%, transparent);
}
/* Row hover: light falling off to the right, not a filled slab. A flat
770px-wide rectangle with a hard right edge is the ugly version, and it also
squares off the Vibe timeline's stacked artwork. */
.track-row:hover {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--ethos-surface1) 85%, transparent) 0%,
color-mix(in srgb, var(--ethos-surface1) 40%, transparent) 38%,
transparent 78%
);
}
/* Artwork frame */
.artwork-frame {
aspect-ratio: 1 / 1;
@@ -217,6 +419,40 @@ html { scroll-behavior: smooth; }
}
.group:hover .play-overlay-btn { transform: translateY(0); }
/* Settings library actions use a compact, stateful button rather than an
unstyled native control. */
.admin-btn {
display: inline-flex;
min-height: 36px;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--ethos-border-hi);
border-radius: 8px;
padding: 8px 12px;
background: var(--ethos-surface1);
color: var(--ethos-text);
font-size: 13px;
font-weight: 500;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}
.admin-btn:hover:not(:disabled) {
background: var(--ethos-surface2);
border-color: color-mix(in srgb, var(--ethos-accent) 50%, transparent);
}
.admin-btn:disabled { cursor: wait; opacity: 0.65; }
.admin-btn--done { border-color: color-mix(in srgb, var(--ethos-green) 60%, transparent); color: var(--ethos-green); }
.admin-btn--error { border-color: color-mix(in srgb, var(--ethos-red) 60%, transparent); color: var(--ethos-red); }
/* Keep row actions available on touch. On pointer-and-hover devices, reveal
them when the row is hovered or any of its controls receives focus. */
.track-row-actions { opacity: 1; transition: opacity 150ms ease; }
@media (hover: hover) and (pointer: fine) {
.track-row-actions { opacity: 0; }
.group:hover .track-row-actions,
.group:focus-within .track-row-actions { opacity: 1; }
}
/* Transport button */
.transport-btn {
width: 40px;
+7 -2
View File
@@ -6,11 +6,16 @@
* as the single source of truth.
*/
/** Deterministic hash → hue (0..359) from an arbitrary string. */
/**
* Deterministic hash hue from an arbitrary string, clamped to the warm band
* (amber rust deep red, 8°..52°). Free-running 0..359 hues produced blue,
* teal and violet placeholder tiles that fight the warm room Ethos asks for;
* a 44° window keeps tiles distinguishable without leaving the palette.
*/
export function hueFromString(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h) % 360;
return 8 + (Math.abs(h) % 45);
}
/**
+83
View File
@@ -0,0 +1,83 @@
/**
* Local playback preferences (this browser only), read synchronously at store
* creation so the audio engine never runs a frame with the wrong values.
*/
export const PLAYBACK_PREF_KEYS = {
prefetchNext: 'muzick.settings.prefetchNext',
crossfadeMs: 'muzick.settings.crossfadeMs',
lastTrack: 'muzick.playback.lastTrack',
} as const;
/** Longest fade the UI offers. Also the longest early-advance lead. */
export const MAX_CROSSFADE_MS = 3000;
/** How far before the end of a track the next one starts buffering. */
export const PREFETCH_LEAD_SECONDS = 20;
/**
* How far into a track the next one starts buffering, whichever comes first
* with PREFETCH_LEAD_SECONDS. A phone that sleeps mid-track gets the whole
* remaining song to pull the next one down instead of a 20 second window.
*/
export const PREFETCH_START_SECONDS = 15;
export const PLAYBACK_PREF_DEFAULTS = {
prefetchNext: true,
/** Short by default: enough to hide the Vibe replan round-trip, short enough
* that it reads as a join rather than a mix. */
crossfadeMs: 400,
} as const;
export function readStoredPrefetchNext(): boolean {
try {
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.prefetchNext);
if (stored === 'true') return true;
if (stored === 'false') return false;
} catch { /* ignore */ }
return PLAYBACK_PREF_DEFAULTS.prefetchNext;
}
export function readStoredCrossfadeMs(): number {
try {
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.crossfadeMs);
if (stored !== null) {
const parsed = Number(stored);
if (Number.isFinite(parsed)) return clampCrossfadeMs(parsed);
}
} catch { /* ignore */ }
return PLAYBACK_PREF_DEFAULTS.crossfadeMs;
}
export function clampCrossfadeMs(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(MAX_CROSSFADE_MS, Math.max(0, Math.round(value)));
}
export function storePrefetchNext(value: boolean): void {
try { localStorage.setItem(PLAYBACK_PREF_KEYS.prefetchNext, String(value)); } catch { /* ignore */ }
}
export function storeCrossfadeMs(value: number): void {
try { localStorage.setItem(PLAYBACK_PREF_KEYS.crossfadeMs, String(value)); } catch { /* ignore */ }
}
/**
* The last track loaded into the player, so a fresh tab shows what was playing
* rather than an empty bar. Restored paused never autoplayed.
* ponytail: stores the whole track object. It is one small row and it saves a
* fetch on boot; if the shape drifts, the parse simply fails and the bar is empty.
*/
export function readStoredLastTrack<T>(): T | null {
try {
const stored = localStorage.getItem(PLAYBACK_PREF_KEYS.lastTrack);
return stored ? (JSON.parse(stored) as T) : null;
} catch { return null; }
}
export function storeLastTrack(track: unknown): void {
try {
if (track) localStorage.setItem(PLAYBACK_PREF_KEYS.lastTrack, JSON.stringify(track));
else localStorage.removeItem(PLAYBACK_PREF_KEYS.lastTrack);
} catch { /* ignore */ }
}
+8 -8
View File
@@ -46,14 +46,14 @@ export default function AlbumDetail() {
return rank(x) - rank(y);
});
return (
<PageContainer className="space-y-8">
<PageContainer className="space-y-6">
<BackLink to="/albums" label="Albums" />
<div className="flex items-end gap-5">
<div className="w-40 h-40 flex-none rounded-xl overflow-hidden">
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="xl" eager />
<div className="flex items-end gap-4 border-b border-border pb-4">
<div className="h-28 w-28 flex-none overflow-hidden rounded-lg sm:h-32 sm:w-32">
<Artwork seed={data.title} src={data.artwork_id} className="w-full h-full" rounded="lg" eager />
</div>
<div className="space-y-2">
<h1 className="text-4xl font-bold text-text">{data.title}</h1>
<div className="min-w-0 space-y-1.5">
<h1 className="truncate text-2xl font-semibold tracking-tight text-text sm:text-3xl">{data.title}</h1>
{artists.length > 0 && (
<ArtistLinks
artists={artists}
@@ -61,7 +61,7 @@ export default function AlbumDetail() {
className="flex flex-wrap items-center gap-x-1 gap-y-0.5 text-sm"
/>
)}
<p className="text-sm text-muted">{data.year ? `${data.year} · ` : ''}{tracks.length} {tracks.length === 1 ? 'track' : 'tracks'}</p>
<p className="font-mono text-xs tabular-nums text-machine">{data.year ? `${data.year} · ` : ''}{tracks.length} tracks</p>
<Button
variant="primary"
icon={<Play size={16} fill="currentColor" />}
@@ -72,7 +72,7 @@ export default function AlbumDetail() {
</Button>
</div>
</div>
<section className="space-y-1">
<section className="track-list">
{tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title="No tracks in this album" />
: tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}
</section>
+10 -6
View File
@@ -25,23 +25,27 @@ export default function Albums() {
return (
<PageContainer>
<PageHeader icon={Disc3} title="Albums" subtitle={albums.length ? `${albums.length} on this page` : undefined} />
<PageHeader
title="Albums"
meta={albums.length ? `${albums.length} shown · page ${page + 1}` : undefined}
/>
{isLoading ? <SkeletonGrid count={10} />
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load albums" subtitle="Something went wrong. Try reloading the page." />
: !albums?.length ? <EmptyState compact icon={<Disc3 size={28} />} title="No albums yet" subtitle="Run a library scan in Settings to populate it." />
: (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{albums.map((album) => (
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
className="card-surface group">
<div className="artwork-frame">
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="xl" />
<Artwork seed={album.title} src={album.artwork_id} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="lg" />
</div>
<div>
<div className="truncate text-sm font-semibold text-text">{album.title}</div>
<div className="truncate text-xs text-muted mt-0.5">
{album.artist_name || 'Unknown artist'}{album.year ? ` · ${album.year}` : ''}
<div className="truncate text-sm font-medium text-text" title={album.title}>{album.title}</div>
<div className="mt-0.5 flex items-baseline gap-1.5 text-xs">
<span className="truncate text-secondary">{album.artist_name || 'Unknown artist'}</span>
{album.year && <span className="flex-none font-mono tabular-nums text-machine">{album.year}</span>}
</div>
</div>
</Link>
+11 -17
View File
@@ -23,28 +23,22 @@ export default function ArtistDetail() {
<PageContainer width="lg" className="space-y-8">
<BackLink to="/artists" label="Artists" />
{/* Hero: blurred backdrop of the artist image + avatar + name */}
<div className="relative overflow-hidden rounded-3xl border border-border/70 animate-rise">
<div className="absolute inset-0">
<Artwork seed={data.name} src={data.image_path} className="h-full w-full scale-110 blur-2xl opacity-40" eager />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/70 to-background/30" />
{/* ponytail: flat hero — the blurred-backdrop version was the frosted-glass trap */}
<div className="animate-rise flex items-end gap-4 border-b border-border pb-4">
<div className="h-24 w-24 flex-none overflow-hidden rounded-full sm:h-28 sm:w-28">
<Artwork seed={data.name} src={data.image_path} className="h-full w-full" rounded="full" eager />
</div>
<div className="relative flex items-end gap-5 p-6 sm:p-8">
<div className="h-28 w-28 sm:h-36 sm:w-36 flex-none rounded-full overflow-hidden ring-4 ring-background shadow-2xl shadow-black/50">
<Artwork seed={data.name} src={data.image_path} className="h-full w-full" rounded="full" eager />
</div>
<div className="min-w-0 pb-1">
<div className="text-xs font-semibold uppercase tracking-wider text-muted">Artist</div>
<h1 className="text-gradient text-4xl sm:text-5xl font-extrabold tracking-tight truncate">{data.name}</h1>
<p className="mt-1.5 text-sm text-muted">{albums.length} {albums.length === 1 ? 'album' : 'albums'}</p>
</div>
<div className="min-w-0">
<div className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted">Artist</div>
<h1 className="truncate text-2xl font-semibold tracking-tight text-text sm:text-3xl">{data.name}</h1>
<p className="mt-1 font-mono text-xs tabular-nums text-machine">{albums.length} albums</p>
</div>
</div>
<section className="space-y-4">
<h2 className="text-xl font-semibold text-text">Albums</h2>
<section className="space-y-3">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted">Albums</h2>
{albums.length === 0 ? <EmptyState compact icon={<Disc3 size={28} />} title="No albums by this artist" /> : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
{albums.map((album) => (
<Link key={album.id} to="/albums/$albumId" params={{ albumId: album.id }}
className="card-surface group">
+8 -5
View File
@@ -25,20 +25,23 @@ export default function Artists() {
return (
<PageContainer>
<PageHeader icon={Users} title="Artists" subtitle="Browse by artist" />
<PageHeader
title="Artists"
meta={artists.length ? `${artists.length} shown · page ${page + 1}` : undefined}
/>
{isLoading ? <SkeletonGrid count={10} />
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load artists" subtitle="Something went wrong. Try reloading the page." />
: !artists?.length ? <EmptyState compact icon={<Users size={28} />} title="No artists yet" subtitle="Run a library scan in Settings to populate it." />
: (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
{artists.map((artist) => (
<Link key={artist.id} to="/artists/$artistId" params={{ artistId: artist.id }}
className="card-surface group items-center p-4">
<div className="w-24 h-24 rounded-full overflow-hidden ring-2 ring-transparent group-hover:ring-accent/40 shadow-lg shadow-black/30 transition-all">
className="card-surface group items-center p-2">
<div className="aspect-square w-full overflow-hidden rounded-full ring-1 ring-border transition-all group-hover:ring-accent/40">
<Artwork seed={artist.name} src={artist.image_path} className="w-full h-full transition-transform duration-500 group-hover:scale-105" rounded="full" />
</div>
<div className="text-sm font-semibold text-text truncate w-full text-center group-hover:text-accent transition-colors">{artist.name}</div>
<div className="w-full truncate text-center text-xs font-medium text-text transition-colors group-hover:text-accent" title={artist.name}>{artist.name}</div>
</Link>
))}
</div>
-120
View File
@@ -1,120 +0,0 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Disc3, Sparkles } from 'lucide-react';
import { genreService } from '../services/genreService';
import { vibeService } from '../services/vibeService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import type { Genre, Track } from '../types';
export default function Discover() {
const [selected, setSelected] = useState<Genre | null>(null);
const { setQueue, playTrack } = usePlaybackStore();
const { setActiveSession, setSeedTrackId, setBuffer } = useVibeStore();
const genres = useQuery<Genre[]>({
queryKey: ['genres'],
queryFn: () => genreService.listGenres(),
});
const genreTracks = useQuery<Track[]>({
queryKey: ['genre-tracks', selected?.id],
queryFn: () => genreService.getGenreTracks(selected!.id),
enabled: !!selected,
});
const startGenreVibe = async () => {
const tracks = genreTracks.data;
if (!tracks || tracks.length === 0) return;
const seed = tracks[0];
try {
const { sessionId } = await vibeService.start(seed.id);
setActiveSession({ sessionId, seedTrackId: seed.id });
setSeedTrackId(seed.id);
setBuffer(tracks);
setQueue(tracks);
playTrack(tracks[0]);
} catch {
setQueue(tracks);
playTrack(tracks[0]);
}
};
return (
<PageContainer>
<header className="space-y-1">
<h1 className="text-3xl font-bold text-text">Discover</h1>
<p className="text-muted">Browse by genre, then play tracks or start a vibe.</p>
</header>
{genres.isLoading ? (
<p className="text-sm text-muted">Loading genres</p>
) : genres.isError ? (
<p className="text-sm text-muted">Couldn't load genres.</p>
) : (genres.data ?? []).length === 0 ? (
<p className="text-sm text-muted">No genres available yet.</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{(genres.data ?? []).map((genre) => {
const active = selected?.id === genre.id;
return (
<button
key={genre.id}
onClick={() => setSelected(genre)}
className={`flex flex-col gap-2 rounded-xl border p-4 text-left transition-colors ${
active
? 'border-accent/60 bg-accent/10'
: 'border-border bg-surface0 hover:bg-surface1'
}`}
>
<Disc3 size={22} className={active ? 'text-accent' : 'text-muted'} />
<div className="truncate font-semibold text-text">{genre.name}</div>
{typeof genre.track_count === 'number' && (
<div className="text-xs text-muted">{genre.track_count} tracks</div>
)}
</button>
);
})}
</div>
)}
{selected && (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-text">{selected.name}</h2>
<button
onClick={() => void startGenreVibe()}
disabled={!genreTracks.data || genreTracks.data.length === 0}
className="flex items-center gap-2 rounded-lg border border-accent/60 bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20 disabled:opacity-50"
>
<Sparkles size={16} />
Start a vibe
</button>
</div>
{genreTracks.isLoading ? (
<p className="text-sm text-muted">Loading tracks</p>
) : genreTracks.isError ? (
<p className="text-sm text-muted">Couldn't load tracks for this genre.</p>
) : (genreTracks.data ?? []).length === 0 ? (
<p className="text-sm text-muted">No tracks found for this genre.</p>
) : (
<div className="space-y-1">
{(genreTracks.data ?? []).map((track, i) => (
<TrackRow
key={`${track.id}-${i}`}
track={track}
queue={genreTracks.data ?? []}
index={i}
showActions={false}
/>
))}
</div>
)}
</section>
)}
</PageContainer>
);
}
+72 -53
View File
@@ -7,14 +7,46 @@ import { TrackRow } from '../components/TrackRow';
import { BackLink } from '../components/BackLink';
import { Button } from '../components/ethos/Button';
import { PageContainer } from '../components/PageContainer';
import { PageHeader } from '../components/PageHeader';
import { Pagination } from '../components/Pagination';
import { EmptyState } from '../components/EmptyState';
import { SkeletonRows, SkeletonGrid } from '../components/LoadingState';
import { hueFromString } from '../lib/color';
import type { Genre, Track } from '../types';
const GENRE_TRACKS_PAGE_SIZE = 50;
/**
* One genre entry. Flat surface, hairline separation, mono count the previous
* version painted every card with a hue derived from its name, which turned the
* page into 40 unrelated colour fields and read as decoration, not information.
*/
function GenreButton({
genre,
onSelect,
primary = false,
}: {
genre: Genre;
onSelect: (g: Genre) => void;
primary?: boolean;
}) {
return (
<button
onClick={() => onSelect(genre)}
className={`group flex w-full items-center gap-2.5 bg-bg1 px-3 text-left transition-colors hover:bg-surface0 ${
primary ? 'h-12 rounded-t-lg' : 'h-11'
}`}
>
<Tag size={primary ? 16 : 14} className="flex-none text-muted transition-colors group-hover:text-accent" />
<span className={`min-w-0 flex-1 truncate ${primary ? 'text-sm font-medium text-text' : 'text-xs text-secondary group-hover:text-text'}`}>
{genre.name}
</span>
<span className="flex-none font-mono text-xs tabular-nums text-machine">
{(genre.track_count ?? 0).toLocaleString()}
</span>
</button>
);
}
export default function Genres() {
const [selected, setSelected] = useState<Genre | null>(null);
const [genrePage, setGenrePage] = useState(0);
@@ -36,22 +68,25 @@ export default function Genres() {
return (
<PageContainer>
<BackLink to="/genres" label="Genres" />
<div className="flex items-center justify-between">
<h1 className="flex items-center gap-3 text-3xl font-bold text-text"><Tag size={28} className="text-accent" />{selected.name}</h1>
<Button
variant="primary"
icon={<Play size={16} fill="currentColor" />}
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
disabled={!tracks.length}
>
Play all
</Button>
</div>
<PageHeader
title={selected.name}
meta={`${(selected.track_count ?? tracks.length).toLocaleString()} tracks`}
actions={
<Button
variant="primary"
icon={<Play size={16} fill="currentColor" />}
onClick={() => { if (tracks.length) { setQueue(tracks); playTrack(tracks[0]); } }}
disabled={!tracks.length}
>
Play all
</Button>
}
/>
{tracksQ.isLoading ? <SkeletonRows count={8} />
: tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title="No tracks in this genre" />
: (
<>
<div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>
<div className="track-list">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>
<Pagination
page={genrePage}
hasNext={hasNext}
@@ -68,60 +103,44 @@ export default function Genres() {
const genres = genresQ.data ?? [];
const roots = genres.filter((g) => !g.parent_id);
const childrenOf = (id: string) => genres.filter((g) => g.parent_id === id);
const orphans = genres.filter((g) => g.parent_id && !genres.some((p) => p.id === g.parent_id));
const flat = [...roots.filter((g) => childrenOf(g.id).length === 0), ...orphans];
return (
<PageContainer>
<h1 className="flex items-center gap-3 text-3xl font-bold text-text"><Tag size={28} className="text-accent" />Genres</h1>
<PageHeader
title="Genres"
meta={genres.length ? `${genres.length} genres · ${roots.length} top level` : undefined}
/>
{genresQ.isLoading ? <SkeletonGrid count={10} />
: genresQ.isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load genres" subtitle="Something went wrong. Try reloading the page." />
: !genres.length ? <EmptyState compact icon={<Tag size={28} />} title="No genres yet" subtitle="Genres appear after you scan and enrich your library." />
: (
<div className="space-y-6">
{roots.map((genre) => {
const hue = hueFromString(genre.name);
<div className="space-y-4">
{roots.filter((g) => childrenOf(g.id).length > 0).map((genre) => {
const subs = childrenOf(genre.id);
return (
<div key={genre.id} className="space-y-2">
<button onClick={() => setSelected(genre)}
className="group flex items-center gap-3 rounded-xl border border-border/70 p-4 w-full text-left transition-colors hover:border-accent/40"
style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}>
<Tag size={20} className="text-muted group-hover:text-accent flex-none" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-text">{genre.name}</div>
<div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div>
</div>
</button>
<section key={genre.id} className="rounded-lg border border-border bg-surface0/40">
<GenreButton genre={genre} onSelect={setSelected} primary />
{subs.length > 0 && (
<div className="ml-6 grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
{subs.map((sub) => {
const subHue = hueFromString(sub.name);
return (
<button key={sub.id} onClick={() => setSelected(sub)}
className="group flex flex-col items-start gap-1 rounded-lg border border-border/70 p-3 text-left transition-colors hover:border-accent/40"
style={{ background: `linear-gradient(135deg, hsl(${subHue},30%,12%), hsl(${(subHue+60)%360},25%,8%))` }}>
<div className="w-full truncate text-sm font-medium text-text">{sub.name}</div>
<div className="text-xs text-muted">{sub.track_count ?? 0} tracks</div>
</button>
);
})}
<div className="grid grid-cols-2 gap-px border-t border-border bg-border/40 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{subs.map((sub) => (
<GenreButton key={sub.id} genre={sub} onSelect={setSelected} />
))}
</div>
)}
</div>
);
})}
{/* Orphan genres (parent_id set but parent not in list) fall back to flat display */}
{genres.filter((g) => g.parent_id && !genres.find((p) => p.id === g.parent_id)).map((genre) => {
const hue = hueFromString(genre.name);
return (
<button key={genre.id} onClick={() => setSelected(genre)}
className="group flex flex-col items-start gap-2 rounded-xl border border-border/70 p-4 text-left transition-colors hover:border-accent/40"
style={{ background: `linear-gradient(135deg, hsl(${hue},40%,15%), hsl(${(hue+60)%360},30%,10%))` }}>
<Tag size={20} className="text-muted group-hover:text-accent" />
<div className="w-full truncate font-medium text-text">{genre.name}</div>
<div className="text-xs text-muted">{genre.track_count ?? 0} tracks</div>
</button>
</section>
);
})}
{/* Childless roots + orphans (parent missing from the list) share one grid
a section per genre wastes a full row on a single tag. */}
{flat.length > 0 && (
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border/40 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{flat.map((genre) => (
<GenreButton key={genre.id} genre={genre} onSelect={setSelected} />
))}
</div>
)}
</div>
)}
</PageContainer>
+32 -31
View File
@@ -1,20 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Zap, Music, Disc3, Users } from 'lucide-react';
import { Zap } from 'lucide-react';
import { ShelfRow } from '../components/ShelfRow';
import { MediaCard } from '../components/MediaCard';
import { PageContainer } from '../components/PageContainer';
import { Skeleton } from '../components/LoadingState';
import { historyService } from '../services/historyService';
import { trackService } from '../services/trackService';
import { fetchLibraryStats, type LibraryStats } from '../services/libraryService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import type { HistoryEntry, Track } from '../types';
const SHORTCUTS = [
{ label: 'Songs', icon: Music, to: '/tracks' as const },
{ label: 'Albums', icon: Disc3, to: '/albums' as const },
{ label: 'Artists', icon: Users, to: '/artists' as const },
];
/** `128h 04m` — total library playtime, sized to read at a glance. */
function formatTotalTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours.toLocaleString()}h ${minutes.toString().padStart(2, '0')}m`;
}
/** A row of placeholder cards matching MediaCard's shelf width. */
function ShelfSkeleton({ count = 6 }: { count?: number }) {
@@ -44,6 +46,17 @@ export default function Home() {
queryFn: () => trackService.listTracks({ limit: 12, sort_by: 'play_count', order: 'DESC' }),
});
const stats = useQuery<LibraryStats>({
queryKey: ['library-stats'],
queryFn: fetchLibraryStats,
staleTime: 60_000,
});
const counts = stats.data
? `${stats.data.tracks.toLocaleString()} tracks · ${stats.data.albums.toLocaleString()} albums · ` +
`${stats.data.artists.toLocaleString()} artists · ${formatTotalTime(stats.data.duration)}`
: null;
const playFrom = (list: Track[], index: number) => {
setQueue(list.slice(index));
playTrack(list[index]);
@@ -53,35 +66,23 @@ export default function Home() {
return (
<PageContainer width="lg" className="space-y-8">
<div className="animate-rise">
<h1 className="text-gradient text-4xl font-extrabold tracking-tight">Good listening</h1>
<p className="text-muted mt-1.5">Your music, your way.</p>
</div>
{/* Vibe hero + quick shortcuts */}
<section className="grid gap-3 sm:grid-cols-[2fr_3fr]">
{/* Vibe hero. The three library shortcuts that used to sit beside it were
duplicates of the nav rail two inches to the left the space now goes
to the one action this page is for, plus what the library actually holds. */}
<section className="animate-rise flex flex-wrap items-end justify-between gap-4 border-b border-border pb-4">
<div className="min-w-0">
<h1 className="text-3xl font-semibold tracking-tight text-text sm:text-4xl">Good listening</h1>
<p className="mt-1.5 font-mono text-xs tabular-nums text-machine">
{counts ?? 'reading library…'}
</p>
</div>
<Link
to="/vibe"
className="group relative overflow-hidden rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/30 to-accent/5 p-5 flex flex-col justify-between min-h-[7rem] transition-all hover:-translate-y-0.5 hover:shadow-xl hover:shadow-accent/10"
className="group flex flex-none items-center gap-2.5 rounded-lg border border-accent/40 bg-accent/10 px-4 py-2.5 text-sm font-medium text-accent transition-colors hover:bg-accent/20"
>
<Zap size={22} className="text-accent transition-transform group-hover:scale-110" />
<div>
<div className="text-lg font-bold text-text">Start a Vibe</div>
<div className="text-xs text-muted">Endless recommendations from your library.</div>
</div>
<Zap size={18} className="transition-transform group-hover:scale-110" />
Start a Vibe
</Link>
<div className="grid grid-cols-3 gap-3">
{SHORTCUTS.map(({ label, icon: Icon, to }) => (
<Link
key={to}
to={to}
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border border-border/70 bg-surface0/60 p-4 transition-all hover:-translate-y-0.5 hover:border-accent/40 hover:bg-surface1"
>
<Icon size={22} className="text-muted transition-colors group-hover:text-accent" />
<span className="text-sm font-semibold text-text">{label}</span>
</Link>
))}
</div>
</section>
<ShelfRow title="Continue Listening" viewAllTo="/tracks">
+58 -33
View File
@@ -2,13 +2,9 @@ import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Activity,
Play,
Pause,
RotateCcw,
Terminal,
CheckCircle,
AlertCircle,
Clock,
ChevronDown,
ChevronRight,
Copy,
@@ -61,16 +57,32 @@ function formatDuration(ms: number): string {
/* ─────────────────────────────────────────── Sub-components ──────────────────────────────────────── */
function StatCard({ icon: Icon, label, value, color }: { icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; label: string; value: number; color: string }) {
/**
* One queue counter. Every tile used to carry its own hue and a giant ghost icon,
* which made a queue at rest look like an alarm panel. Now the number is mono and
* neutral; colour appears only when the value is one that wants attention.
*/
function StatCard({
label,
value,
tone = 'neutral',
}: {
label: string;
value: number;
tone?: 'neutral' | 'active' | 'bad';
}) {
const live = value > 0;
const valueColor = !live
? 'text-disabled'
: tone === 'bad'
? 'text-red'
: tone === 'active'
? 'text-accent'
: 'text-text';
return (
<div className="bg-surface0 border border-border rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted uppercase tracking-wide">{label}</p>
<p className="text-3xl font-bold mt-1" style={{ color }}>{value.toLocaleString()}</p>
</div>
<Icon className="w-8 h-8 opacity-30" style={{ color }} />
</div>
<div className="rounded-lg border border-border bg-surface0/50 px-3 py-2.5">
<p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted">{label}</p>
<p className={`mt-1 font-mono text-2xl tabular-nums ${valueColor}`}>{value.toLocaleString()}</p>
</div>
);
}
@@ -384,29 +396,38 @@ export default function JobsPage() {
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* ── Header ── */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex flex-wrap items-end justify-between gap-3 border-b border-border p-4">
<div>
<h1 className="text-xl font-semibold">Jobs</h1>
<p className="text-sm text-muted">Background task queue monitoring</p>
<h1 className="text-2xl font-semibold tracking-tight text-text sm:text-3xl">Jobs</h1>
<p className="mt-1 font-mono text-xs tabular-nums text-machine">
{stats
? `${stats.waiting + stats.active + stats.delayed} queued · ${stats.failed} failed`
: 'reading queue…'}
{autoRefresh ? ' · refresh 5s' : ' · refresh off'}
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<button
onClick={() => { refetchStats(); refetchHistory(); }}
className="flex items-center gap-1.5 text-xs text-muted hover:text-text transition-colors"
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-text"
title="Refresh now"
>
<RefreshCw className="w-3.5 h-3.5" />
Refresh
</button>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="w-4 h-4 accent-accent"
/>
<button
type="button"
onClick={() => setAutoRefresh(!autoRefresh)}
aria-pressed={autoRefresh}
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors ${
autoRefresh
? 'border-accent/50 bg-accent/10 text-accent'
: 'border-border text-secondary hover:text-text'
}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${autoRefresh ? 'bg-accent' : 'bg-disabled'}`} />
Auto-refresh
</label>
</button>
</div>
</div>
@@ -451,16 +472,20 @@ export default function JobsPage() {
{/* ── Overview tab ── */}
{selectedTab === 'overview' && !stats && !loadError && (
<div className="text-center py-16 text-muted text-sm">Loading queue stats</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton h-[70px] rounded-lg" />
))}
</div>
)}
{selectedTab === 'overview' && stats && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard icon={Clock} label="Waiting" value={stats.waiting} color="#eab308" />
<StatCard icon={Play} label="Active" value={stats.active} color="#a78bfa" />
<StatCard icon={CheckCircle} label="Completed" value={stats.completed} color="#22c55e" />
<StatCard icon={AlertCircle} label="Failed" value={stats.failed} color="#f87171" />
<StatCard icon={RotateCcw} label="Delayed" value={stats.delayed} color="#60a5fa" />
<StatCard icon={Pause} label="Paused" value={stats.paused} color="#6b7280" />
<StatCard label="Waiting" value={stats.waiting} />
<StatCard label="Active" value={stats.active} tone="active" />
<StatCard label="Completed" value={stats.completed} />
<StatCard label="Failed" value={stats.failed} tone="bad" />
<StatCard label="Delayed" value={stats.delayed} />
<StatCard label="Paused" value={stats.paused} />
</div>
)}
+16 -18
View File
@@ -1,8 +1,9 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ShieldAlert, RotateCcw, Trash2, Clock, AlertCircle } from 'lucide-react';
import { ShieldAlert, RotateCcw, Trash2, AlertCircle } from 'lucide-react';
import { quarantineService } from '../services/quarantineService';
import { Badge } from '../components/ethos/Badge';
import { PageContainer } from '../components/PageContainer';
import { PageHeader } from '../components/PageHeader';
import { EmptyState } from '../components/EmptyState';
import { SkeletonRows } from '../components/LoadingState';
import { toast } from '../store/useToastStore';
@@ -56,12 +57,11 @@ export default function Quarantine() {
return (
<PageContainer>
<div>
<h1 className="flex items-center gap-3 text-3xl font-bold text-text">
<ShieldAlert size={28} className="text-accent" /> Quarantine
</h1>
<p className="text-muted mt-1">Disliked tracks pending deletion. Restore before the timer expires.</p>
</div>
<PageHeader
title="Quarantine"
subtitle="Disliked tracks pending deletion. Restore before the timer expires."
meta={entries.length ? `${entries.length} held` : undefined}
/>
{isLoading ? <SkeletonRows count={3} />
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load quarantine list" subtitle="Something went wrong. Try reloading the page." />
@@ -72,25 +72,23 @@ export default function Quarantine() {
subtitle="Disliked tracks will appear here during the grace period before being deleted."
/>
) : (
<ul className="space-y-2">
<ul className="track-list rounded-lg border border-border bg-surface0/40 px-1.5">
{entries.map((entry) => (
<li key={entry.track_id} className="flex items-center gap-3 rounded-lg border border-border bg-surface0 p-3">
<li key={entry.track_id} className="flex h-14 items-center gap-2.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-text truncate">{entry.track_title}</span>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-text">{entry.track_title}</span>
{stateLabel(entry.state)}
</div>
<div className="text-xs text-muted">{entry.track_artist}</div>
<div className="flex items-center gap-1 mt-1 text-xs text-muted">
<Clock size={12} /> {countdown(entry)}
</div>
<div className="truncate text-xs text-secondary">{entry.track_artist}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="flex-none font-mono text-xs tabular-nums text-machine">{countdown(entry)}</span>
<div className="flex flex-none items-center gap-1.5">
<button
onClick={() => restore.mutate(entry.track_id)}
disabled={restore.isPending}
title="Restore to library"
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-text hover:bg-surface1 disabled:opacity-50 transition-colors"
className="flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-text transition-colors hover:bg-surface1 disabled:opacity-50"
>
<RotateCcw size={14} /> Restore
</button>
@@ -98,7 +96,7 @@ export default function Quarantine() {
onClick={() => { if (confirm(`Permanently delete "${entry.track_title}"?`)) hardDelete.mutate(entry.track_id); }}
disabled={hardDelete.isPending}
title="Delete now"
className="flex items-center gap-1.5 rounded-lg border border-red-500/40 px-3 py-1.5 text-sm text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition-colors"
className="flex items-center gap-1.5 rounded-md border border-red-500/40 px-2 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/10 disabled:opacity-50"
>
<Trash2 size={14} /> Delete
</button>
+239
View File
@@ -0,0 +1,239 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, Compass } from 'lucide-react';
import { discoveryService } from '../services/discoveryService';
import type { DiscoveryRow, DiscoverySourceSummary } from '../services/discoveryService';
import { Badge } from '../components/ethos/Badge';
import { PageContainer } from '../components/PageContainer';
import { PageHeader } from '../components/PageHeader';
import { EmptyState } from '../components/EmptyState';
import { SkeletonRows } from '../components/LoadingState';
// Mirrors the probation sweep in workers/src/index.ts. Shown rather than hidden:
// a track on probation is N plays away from staying, and the operator should be
// able to see exactly how far. If the sweep's thresholds change, change these.
const KEEP_AFTER_PLAYS = 3;
const DROP_AFTER_SKIPS = 3;
type Filter = 'all' | 'probation' | 'retained' | 'retired' | 'stalled';
const FILTERS: { id: Filter; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'probation', label: 'On probation' },
{ id: 'retained', label: 'Kept' },
{ id: 'retired', label: 'Removed' },
{ id: 'stalled', label: 'Never arrived' },
];
/** A candidate that was evaluated but never became a track. */
const isStalled = (row: DiscoveryRow) => !row.track_id && row.status !== 'candidate';
function matches(row: DiscoveryRow, filter: Filter): boolean {
if (filter === 'all') return true;
if (filter === 'stalled') return isStalled(row);
return row.probation_status === filter;
}
function sourceLabel(source: string): string {
if (source === 'new_release') return 'New release';
if (source === 'similar_recommendation') return 'Similar';
if (source === 'graph_exploration') return 'Graph';
return source;
}
function statusBadge(row: DiscoveryRow) {
if (row.probation_status === 'retained') return <Badge color="green">Kept</Badge>;
if (row.probation_status === 'retired') return <Badge color="neutral">Removed</Badge>;
if (row.probation_status === 'probation') return <Badge color="amber" dot>On probation</Badge>;
if (isStalled(row)) return <Badge color="red">{row.status.replace(/_/g, ' ')}</Badge>;
return <Badge color="neutral">Queued</Badge>;
}
function shortDate(value: string | null): string {
if (!value) return '—';
return new Date(value).toISOString().slice(0, 10);
}
/** Where this candidate came from, in one line, without a second request. */
function provenance(row: DiscoveryRow): string {
const notes = row.notes ?? {};
if (notes.album) return `from ${notes.album}`;
if (notes.seed_artist) return `because you play ${notes.seed_artist}`;
return sourceLabel(row.source);
}
function displayName(row: DiscoveryRow): { title: string; artist: string } {
const credited = row.artist_credit?.[0]?.name ?? '';
return {
title: row.track_title ?? row.title ?? 'Untitled',
artist: row.track_artist ?? credited,
};
}
function SummaryStrip({ summary }: { summary: DiscoverySourceSummary[] }) {
if (summary.length === 0) return null;
return (
<div className="grid gap-2 sm:grid-cols-2">
{summary.map((s) => (
<div key={s.source} className="rounded-lg border border-border bg-surface0/40 p-3">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium text-text">{sourceLabel(s.source)}</span>
<span className="flex-none font-mono text-xs tabular-nums text-machine">
{s.candidates} found
</span>
</div>
<dl className="mt-2 flex flex-wrap gap-x-4 gap-y-1 font-mono text-xs tabular-nums">
<div className="flex gap-1.5">
<dt className="font-sans text-secondary">kept</dt>
<dd className="text-green">{s.retained}</dd>
</div>
<div className="flex gap-1.5">
<dt className="font-sans text-secondary">trying</dt>
<dd className="text-amber">{s.probation}</dd>
</div>
<div className="flex gap-1.5">
<dt className="font-sans text-secondary">removed</dt>
<dd className="text-machine">{s.retired}</dd>
</div>
<div className="flex gap-1.5">
<dt className="font-sans text-secondary">never arrived</dt>
<dd className={s.stalled > 0 ? 'text-red' : 'text-machine'}>{s.stalled}</dd>
</div>
</dl>
</div>
))}
</div>
);
}
export default function Recommendations() {
const [filter, setFilter] = useState<Filter>('all');
const { data, isLoading, isError } = useQuery({
queryKey: ['discovery-overview'],
queryFn: () => discoveryService.overview(),
refetchInterval: 60_000,
});
const rows = data?.rows ?? [];
const visible = useMemo(() => rows.filter((row) => matches(row, filter)), [rows, filter]);
const totals = data?.summary.reduce(
(acc, s) => ({
candidates: acc.candidates + s.candidates,
retained: acc.retained + s.retained,
probation: acc.probation + s.probation,
}),
{ candidates: 0, retained: 0, probation: 0 }
);
return (
<PageContainer>
<PageHeader
title="Recommendations"
subtitle="What discovery brought in, and what your skips did with it."
meta={
totals
? `${totals.candidates} found · ${totals.retained} kept · ${totals.probation} on probation`
: undefined
}
/>
{isLoading ? (
<SkeletonRows count={5} />
) : isError ? (
<EmptyState
compact
icon={<AlertCircle size={28} />}
title="Couldn't load recommendations"
subtitle="Something went wrong. Try reloading the page."
/>
) : rows.length === 0 ? (
<EmptyState
icon={<Compass size={28} />}
title="Nothing discovered yet"
subtitle="The new-release and similarity scans run daily. Acquisition also has to be enabled on the worker before a candidate can become a track."
/>
) : (
<>
<SummaryStrip summary={data?.summary ?? []} />
<div className="flex flex-wrap gap-1.5">
{FILTERS.map((f) => {
const count = rows.filter((row) => matches(row, f.id)).length;
const active = filter === f.id;
return (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors ${
active
? 'border-accent/60 bg-accent/10 text-accent'
: 'border-border text-secondary hover:bg-surface1'
}`}
>
{f.label}
<span className="font-mono tabular-nums text-[11px] text-machine">{count}</span>
</button>
);
})}
</div>
{visible.length === 0 ? (
<EmptyState compact icon={<Compass size={28} />} title="Nothing in this state" />
) : (
<ul className="track-list rounded-lg border border-border bg-surface0/40 px-1.5">
{visible.map((row) => {
const { title, artist } = displayName(row);
const plays = row.completed_plays ?? 0;
const skips = row.quick_skips ?? 0;
// Stacked below 640px: at that width the name, the badge and the
// mono readout cannot share a row without the title collapsing
// to two characters.
return (
<li
key={row.id}
className="flex flex-col gap-1 py-2 sm:min-h-14 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2.5"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-text">{title}</span>
{statusBadge(row)}
</div>
<div className="truncate text-xs text-secondary">
{artist ? `${artist} · ` : ''}
{provenance(row)}
</div>
{row.last_error && (
<div className="mt-0.5 truncate font-mono text-[11px] text-red" title={row.last_error}>
{row.last_error}
</div>
)}
</div>
<div className="flex items-center gap-3 font-mono text-xs tabular-nums text-machine sm:flex-none">
{row.probation_status === 'probation' ? (
<span title="Completed plays that keep it, quick skips that drop it">
{plays}/{KEEP_AFTER_PLAYS} plays · {skips}/{DROP_AFTER_SKIPS} skips
</span>
) : row.track_id ? (
<span title="Completed plays · quick skips">
{plays} plays · {skips} skips
</span>
) : (
<span title="Acquisition attempts">
{row.acquisition_attempts} attempts
</span>
)}
<span className="text-muted" title="Acquired, or first seen">
{shortDate(row.acquired_at ?? row.first_seen_at)}
</span>
</div>
</li>
);
})}
</ul>
)}
</>
)}
</PageContainer>
);
}
+7 -12
View File
@@ -4,6 +4,7 @@ import { Search as SearchIcon, SearchX } from 'lucide-react';
import { searchService } from '../services/searchService';
import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import { PageHeader } from '../components/PageHeader';
import { EmptyState } from '../components/EmptyState';
import { LoadingState } from '../components/LoadingState';
import type { SearchResponse, Track } from '../types';
@@ -26,17 +27,11 @@ export default function Search() {
return (
<PageContainer>
<div className="animate-rise">
<h1 className="text-gradient text-3xl font-extrabold tracking-tight sm:text-4xl">Search</h1>
{query.length > 0 ? (
<p className="mt-1.5 text-sm text-muted">
{busy ? 'Searching' : `${tracks.length} ${tracks.length === 1 ? 'result' : 'results'}`} for{' '}
<span className="font-semibold text-text">{query}</span>
</p>
) : (
<p className="mt-1.5 text-sm text-muted">Search your library from the bar above.</p>
)}
</div>
<PageHeader
title="Search"
subtitle={query.length > 0 ? `${query}` : 'Search your library from the bar above.'}
meta={query.length > 0 ? (busy ? 'searching…' : `${tracks.length} results`) : undefined}
/>
{query.length === 0 ? (
<EmptyState
@@ -51,7 +46,7 @@ export default function Search() {
) : tracks.length === 0 ? (
<EmptyState icon={<SearchX size={28} />} title="No results" subtitle={`Nothing matched “${query}”.`} />
) : (
<section className="space-y-1 rounded-2xl border border-border/70 bg-surface0/40 p-2 animate-rise">
<section className="track-list animate-rise rounded-lg border border-border bg-surface0/30 px-1 py-1">
{tracks.map((t, i) => (
<TrackRow key={t.id} track={t} queue={tracks} index={i} />
))}
+93 -20
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
import { Volume2, Info, Scan, RefreshCw, Globe, Copy, ChevronDown, ChevronRight, Trash2, Users, Sparkles } from 'lucide-react';
import { Volume2, Info, Scan, RefreshCw, Globe, Copy, ChevronDown, ChevronRight, Trash2, Users, Sparkles, Radio } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { PageContainer } from '../components/PageContainer';
import { PageHeader } from '../components/PageHeader';
import api from '../services/api';
import { settingsService, type EnrichSettingKey, type EnrichSettings } from '../services/settingsService';
import { STORAGE_KEYS, readStoredVolume } from '../lib/theme';
import { MAX_CROSSFADE_MS, PREFETCH_LEAD_SECONDS } from '../lib/playbackPrefs';
import { toast } from '../store/useToastStore';
import type { Track } from '../types';
@@ -21,15 +23,29 @@ const ENRICH_LABELS: Record<EnrichSettingKey, { label: string; desc: string }> =
function EnrichToggles() {
const [settings, setSettings] = useState<EnrichSettings | null>(null);
const [saving, setSaving] = useState<EnrichSettingKey | null>(null);
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading');
const [updateError, setUpdateError] = useState<string | null>(null);
const loadSettings = async () => {
setLoadState('loading');
setUpdateError(null);
try {
setSettings(await settingsService.load());
setLoadState('ready');
} catch {
setLoadState('error');
}
};
useEffect(() => {
settingsService.load().then(setSettings).catch(() => {});
void loadSettings();
}, []);
const toggle = async (key: EnrichSettingKey) => {
if (!settings || saving) return;
const next = !settings[key];
setSaving(key);
setUpdateError(null);
// Optimistic update.
setSettings((prev) => prev ? { ...prev, [key]: next } : prev);
try {
@@ -37,12 +53,28 @@ function EnrichToggles() {
} catch {
// Revert on failure.
setSettings((prev) => prev ? { ...prev, [key]: !next } : prev);
setUpdateError(`Could not update ${ENRICH_LABELS[key].label}. Your previous setting was restored.`);
} finally {
setSaving(null);
}
};
if (!settings) return null;
if (loadState === 'loading') {
return <p className="pt-2 text-xs text-muted" role="status">Loading enrichment settings</p>;
}
if (loadState === 'error' || !settings) {
return (
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
<p className="text-xs text-red-400" role="alert">Couldn&apos;t load enrichment settings.</p>
<button type="button" onClick={() => void loadSettings()} className="rounded-lg border border-border px-3 py-1.5 text-xs text-text hover:bg-surface1">
Retry
</button>
</div>
);
}
const enabledCount = Object.values(settings).filter(Boolean).length;
return (
<div className="space-y-3 pt-2 border-t border-border">
@@ -53,18 +85,23 @@ function EnrichToggles() {
Which external services to query during library scan. Changes apply to
the <strong>next scan</strong>.
</p>
<p className="text-xs text-muted" role="status" aria-live="polite">
{saving ? `Saving ${ENRICH_LABELS[saving].label}` : `${enabledCount} of ${Object.keys(ENRICH_LABELS).length} enrichment jobs enabled.`}
</p>
{updateError && <p className="text-xs text-red-400" role="alert">{updateError}</p>}
{Object.entries(ENRICH_LABELS).map(([key, { label, desc }]) => {
const k = key as EnrichSettingKey;
const on = settings[k];
return (
<button key={k} onClick={() => toggle(k)} disabled={saving === k}
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border/70 px-4 py-3 text-left transition-colors hover:bg-surface1 disabled:opacity-50">
<button type="button" key={k} onClick={() => void toggle(k)} disabled={saving !== null}
role="switch" aria-checked={on}
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2 text-left transition-colors hover:bg-surface1 disabled:opacity-50">
<div className="min-w-0">
<div className="text-sm font-medium text-text">{label}</div>
<div className="text-xs text-muted truncate">{desc}</div>
</div>
<div className={`shrink-0 relative w-10 h-5 rounded-full transition-colors ${on ? 'bg-accent' : 'bg-surface2'}`}>
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full shadow-sm transition-transform ${on ? 'bg-on-accent translate-x-5' : 'bg-secondary'}`} />
<div className={`relative h-[20px] w-[36px] shrink-0 rounded-full transition-colors ${on ? 'bg-accent' : 'bg-surface2'}`}>
<div className={`absolute left-[2px] top-[2px] h-[16px] w-[16px] rounded-full transition-transform ${on ? 'bg-on-accent translate-x-[16px]' : 'bg-secondary'}`} />
</div>
</button>
);
@@ -110,9 +147,11 @@ function AdminAction({
return (
<button
type="button"
onClick={handleClick}
disabled={state === 'loading'}
className={`admin-btn ${state === 'done' ? 'admin-btn--done' : state === 'error' ? 'admin-btn--error' : ''}`}
aria-live="polite"
>
<Icon size={16} className={state === 'loading' ? 'animate-spin' : ''} />
{state === 'loading' ? busyLabel
@@ -241,6 +280,10 @@ function DuplicatesSection() {
export default function Settings() {
const volume = usePlaybackStore((s) => s.volume);
const setVolume = usePlaybackStore((s) => s.setVolume);
const prefetchNext = usePlaybackStore((s) => s.prefetchNext);
const setPrefetchNext = usePlaybackStore((s) => s.setPrefetchNext);
const crossfadeMs = usePlaybackStore((s) => s.crossfadeMs);
const setCrossfadeMs = usePlaybackStore((s) => s.setCrossfadeMs);
useEffect(() => {
setVolume(readStoredVolume(volume));
@@ -253,24 +296,54 @@ export default function Settings() {
};
return (
<PageContainer width="sm" className="space-y-8 lg:max-w-3xl">
<div>
<h1 className="text-3xl font-bold text-text">Settings</h1>
<p className="text-muted mt-1">Preferences are stored locally in this browser.</p>
</div>
<PageContainer width="sm" className="space-y-6 lg:max-w-3xl">
<PageHeader title="Settings" subtitle="Preferences are stored locally in this browser." />
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Volume2 size={20} className="text-accent" />Default volume</h2>
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Volume2 size={15} className="text-accent" />Default volume</h2>
<div className="flex items-center gap-4">
<input type="range" min={0} max={1} step={0.01} value={volume}
onChange={(e) => handleVolume(Number(e.target.value))}
className="flex-1" aria-label="Volume" />
<span className="w-12 text-right text-sm tabular-nums text-text">{Math.round(volume * 100)}%</span>
<span className="w-12 flex-none text-right font-mono text-xs tabular-nums text-machine">{Math.round(volume * 100)}%</span>
</div>
</section>
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-4">
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Scan size={20} className="text-accent" />Library</h2>
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Radio size={15} className="text-accent" />Transitions</h2>
<button type="button" onClick={() => setPrefetchNext(!prefetchNext)}
role="switch" aria-checked={prefetchNext}
className="w-full flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2 text-left transition-colors hover:bg-surface1">
<div className="min-w-0">
<div className="text-sm font-medium text-text">Preload next track</div>
<div className="text-xs text-muted">Buffers the upcoming track {PREFETCH_LEAD_SECONDS}s before the current one ends</div>
</div>
<div className={`relative h-[20px] w-[36px] shrink-0 rounded-full transition-colors ${prefetchNext ? 'bg-accent' : 'bg-surface2'}`}>
<div className={`absolute left-[2px] top-[2px] h-[16px] w-[16px] rounded-full transition-transform ${prefetchNext ? 'bg-on-accent translate-x-[16px]' : 'bg-secondary'}`} />
</div>
</button>
<div className="space-y-2">
<label htmlFor="crossfade" className="text-sm font-medium text-text">Crossfade</label>
<div className="flex items-center gap-4">
<input id="crossfade" type="range" min={0} max={MAX_CROSSFADE_MS} step={100} value={crossfadeMs}
onChange={(e) => setCrossfadeMs(Number(e.target.value))}
className="flex-1" />
<span className="w-12 flex-none text-right font-mono text-xs tabular-nums text-machine">
{crossfadeMs === 0 ? 'Off' : `${(crossfadeMs / 1000).toFixed(1)}s`}
</span>
</div>
<p className="text-xs text-muted/70">
Overlaps the end of one track with the start of the next. Off still
joins tracks without a pause the next one is resolved and loaded
during the last second, then starts the moment this one ends.
</p>
</div>
</section>
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Scan size={15} className="text-accent" />Library</h2>
<div className="flex flex-wrap gap-3">
<AdminAction icon={Scan} label="Scan library" busyLabel="Scanning…" doneLabel="Scan enqueued"
@@ -296,7 +369,7 @@ export default function Settings() {
</div>
<p className="text-xs text-muted/70 -mt-1">
<strong>Reprocess artists</strong> re-resolves canonical names, MBIDs and
images for every artist, and merges duplicates. <strong>Re-enrich metadata</strong>
images for every artist, and merges duplicates. <strong>Re-enrich metadata</strong>{' '}
re-queries MusicBrainz/Discogs for all tracks (album titles, years, cover
art) without re-scanning files. Both run in the background.
</p>
@@ -305,8 +378,8 @@ export default function Settings() {
<DuplicatesSection />
</section>
<section className="rounded-xl border border-border/70 bg-surface0 p-5 space-y-3">
<h2 className="text-lg font-semibold text-text flex items-center gap-2"><Info size={20} className="text-accent" />About</h2>
<section className="rounded-xl border border-border bg-surface0/50 p-3 space-y-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-text"><Info size={15} className="text-accent" />About</h2>
<dl className="space-y-2 text-sm">
<div className="flex justify-between"><dt className="text-muted">Application</dt><dd className="text-text font-medium">muzick</dd></div>
<div className="flex justify-between"><dt className="text-muted">Version</dt><dd className="text-text font-medium tabular-nums">0.1.0</dd></div>
+5 -2
View File
@@ -24,11 +24,14 @@ export default function Tracks() {
return (
<PageContainer>
<PageHeader icon={Music} title="Songs" subtitle="Everything in your library" />
<PageHeader
title="Songs"
meta={tracks.length ? `${tracks.length} shown · page ${page + 1}` : undefined}
/>
{isLoading ? <SkeletonRows count={8} />
: isError ? <EmptyState compact icon={<AlertCircle size={28} />} title="Couldn't load tracks" subtitle="Something went wrong. Try reloading the page." />
: tracks.length === 0 ? <EmptyState compact icon={<Music size={28} />} title={page === 0 ? 'No tracks yet' : 'No more tracks'} subtitle={page === 0 ? 'Run a library scan in Settings to populate it.' : undefined} />
: <div className="space-y-1">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
: <div className="track-list">{tracks.map((t, i) => <TrackRow key={t.id} track={t} queue={tracks} index={i} />)}</div>}
{(tracks.length > 0 || page > 0) && (
<Pagination
page={page}
+135 -182
View File
@@ -1,78 +1,66 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Heart, Loader2, Play, Shuffle, ThumbsDown, Sparkles, Square } from 'lucide-react';
import { vibeService, fetchNextBatch } from '../services/vibeService';
import { Loader2, Play, Shuffle, ThumbsDown, Square } from 'lucide-react';
import { advanceVibe, endVibeSession, startVibeSession, vibeErrorMessage } from '../services/vibeSession';
import { trackService } from '../services/trackService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import { PageHeader } from '../components/PageHeader';
import type { Track } from '../types';
import { VibeTimeline } from '../components/VibeTimeline';
import { suppressAutoFeedback } from '../components/AudioEngine';
import { toast } from '../store/useToastStore';
import { VibeAura } from '../components/VibeAura';
const INITIAL_BATCH_SIZE = 5;
const PREFETCH_THRESHOLD = 3;
const PREFETCH_BATCH_SIZE = 3;
function bestEffort(p: Promise<unknown>): void {
void p.catch(() => undefined);
}
const SEED_LIST_SIZE = 50;
export default function Vibe() {
const { currentTrack, queue, setQueue, playTrack, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
const { currentTrack, queue } = usePlaybackStore();
const {
activeSessionId,
buffer,
setActiveSession,
setSeedTrackId,
setCenterTrack,
setBuffer,
appendBuffer,
reset,
initialBatchStatus,
profile,
} = useVibeStore();
const [starting, setStarting] = useState(false);
const [prefetching, setPrefetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [empty, setEmpty] = useState(false);
const prefetchingRef = useRef(false);
const startingRef = useRef(false);
const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery<Track[]>({
// The sample is drawn in the database and is uniform over everything
// playable, so Surprise me still reaches the whole library. Reading all 5000
// tracks to display fifty of them was megabytes of JSON for a phone to parse
// before this page could paint.
const { data: seeds, isLoading: libraryLoading } = useQuery({
queryKey: ['library-seed'],
queryFn: () => trackService.listTracks({ limit: 50, sort_by: 'play_count', order: 'DESC' }),
queryFn: () => trackService.listSeedTracks(SEED_LIST_SIZE),
enabled: !activeSessionId,
});
const seedTracks = useMemo(() => seeds?.tracks ?? [], [seeds]);
const eligibleCount = seeds?.total ?? 0;
const startSession = useCallback(
async (seed: Track) => {
if (startingRef.current) return;
startingRef.current = true;
setStarting(true);
setError(null);
setEmpty(false);
try {
const { sessionId } = await vibeService.start(seed.id);
setActiveSession({ sessionId, seedTrackId: seed.id });
setSeedTrackId(seed.id);
setCenterTrack(seed);
const chunk = await fetchNextBatch(INITIAL_BATCH_SIZE);
if (chunk.length === 0) {
setBuffer([]);
const result = await startVibeSession(seed);
if (result.tracks.length === 0) {
setEmpty(true);
return;
setError('Could not load recommendations for this vibe. Please try another seed.');
}
setBuffer(chunk);
setQueue(chunk);
playTrack(chunk[0]);
} catch {
setError('Could not start a vibe session. Please try again.');
reset();
} catch (startError) {
setError(vibeErrorMessage(startError));
} finally {
startingRef.current = false;
setStarting(false);
}
},
[setActiveSession, setSeedTrackId, setCenterTrack, setBuffer, setQueue, playTrack, reset]
[]
);
const startFromCurrent = useCallback(() => {
@@ -80,101 +68,55 @@ export default function Vibe() {
}, [currentTrack, startSession]);
const surpriseMe = useCallback(() => {
if (libraryTracks.length === 0) return;
const seed = libraryTracks[Math.floor(Math.random() * libraryTracks.length)];
if (seedTracks.length === 0) return;
const seed = seedTracks[Math.floor(Math.random() * seedTracks.length)];
void startSession(seed);
}, [libraryTracks, startSession]);
const remaining = currentTrack
? queue.length - (queue.findIndex((t) => t.id === currentTrack.id) + 1)
: queue.length;
useEffect(() => {
if (!activeSessionId || prefetchingRef.current) return;
if (remaining > PREFETCH_THRESHOLD) return;
prefetchingRef.current = true;
setPrefetching(true);
fetchNextBatch(PREFETCH_BATCH_SIZE)
.then((chunk) => {
if (chunk.length > 0) {
appendBuffer(chunk);
const current = usePlaybackStore.getState().queue;
const currentIds = new Set(current.map((t) => t.id));
const fresh = chunk.filter((t) => !currentIds.has(t.id));
if (fresh.length > 0) setQueue([...current, ...fresh]);
}
})
.catch(() => undefined)
.finally(() => {
prefetchingRef.current = false;
setPrefetching(false);
});
}, [activeSessionId, remaining, appendBuffer, setQueue]);
// Trim buffer to prevent unbounded growth — keep only from currentTrack onward.
useEffect(() => {
if (!activeSessionId || !currentTrack || buffer.length === 0) return;
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
if (idx > 0) {
setBuffer(buffer.slice(idx));
}
}, [activeSessionId, currentTrack, buffer, setBuffer]);
const handleKeep = useCallback(() => {
if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
toast.success(`Kept "${currentTrack.title}"`);
}
}, [currentTrack]);
}, [seedTracks, startSession]);
const handleDislike = useCallback(() => {
if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
// AudioEngine would otherwise also record a 'skipped' on the track
// change caused by playNext() below — suppress that duplicate.
suppressAutoFeedback(currentTrack.id);
void advanceVibe('disliked').catch((feedbackError) => setError(vibeErrorMessage(feedbackError)));
}
playNext();
}, [currentTrack, playNext]);
}, [currentTrack]);
const handleEnd = useCallback(() => {
// V2 plan expires via Redis TTL (2h). No explicit end endpoint.
pause();
setQueue([]);
setCurrentTrack(null);
reset();
void endVibeSession().catch((endError) => setError(vibeErrorMessage(endError)));
setEmpty(false);
setError(null);
}, [reset, pause, setQueue, setCurrentTrack]);
}, []);
const upcoming = currentTrack
? (() => {
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
return idx >= 0 ? buffer.slice(idx + 1) : buffer;
})()
: buffer;
// The aura shows the profile as light, which is not readable. These are the
// same numbers the director steers on (Ethos law 5 — show the machinery).
const profileMeta = useMemo(() => {
if (initialBatchStatus === 'loading') return 'planning…';
const pct = (value: number | undefined, fallback: number) =>
`${Math.round(Math.min(1, Math.max(0, value ?? fallback)) * 100)}%`;
const parts = [
`energy ${pct(profile.energy, 0.5)}`,
`discovery ${pct(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3)}`,
];
const goal = profile.sessionGoal;
if (goal?.target) parts.push(`goal ${goal.progress ?? 0}/${goal.target}`);
return parts.join(' · ');
}, [profile, initialBatchStatus]);
// Read what is actually queued rather than the plan preview: the seed plays
// first and is not a plan item, so the preview alone would misreport what's next.
const upcoming = useMemo(() => {
const index = queue.findIndex((track) => track.id === currentTrack?.id);
return index >= 0 ? queue.slice(index + 1) : buffer;
}, [queue, currentTrack, buffer]);
// ---- Start screen (no active session) ----
if (!activeSessionId) {
return (
<PageContainer width="sm" className="py-8">
<header className="space-y-2 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-accent/10 px-4 py-1.5 text-sm text-accent">
<Sparkles size={16} />
Rolling Vibe
</div>
<h1 className="text-4xl font-bold text-text">Start a Vibe</h1>
<p className="text-muted">
An infinite, ever-rolling stream of recommendations seeded from a track you love.
</p>
<p className="mx-auto max-w-md text-xs text-muted/70">
Pick a seed below and playback starts immediately, with a rolling
timeline of upcoming tracks. <strong className="text-muted">Keep</strong> what you love,
<strong className="text-muted"> Dislike &amp; skip</strong> what you don&apos;t the vibe
adapts as you go.
</p>
</header>
<PageContainer>
{/* Three stacked paragraphs of explanation used to sit here. A seed, a
shuffle and a track list say the same thing by being used. */}
<PageHeader
title="Start a Vibe"
subtitle="Pick a seed. Playback starts at once and the queue re-plans as you keep or skip."
meta={eligibleCount ? `${eligibleCount.toLocaleString()} tracks eligible` : undefined}
/>
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
@@ -182,52 +124,55 @@ export default function Vibe() {
</div>
)}
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2">
{currentTrack && (
<button
onClick={startFromCurrent}
disabled={starting}
className="flex w-full items-center gap-3 rounded-lg border border-accent/60 bg-accent/10 p-4 text-left transition-colors hover:bg-accent/20 disabled:opacity-60"
className="flex h-16 w-full items-center gap-3 rounded-lg border border-accent/50 bg-accent/10 px-3 text-left transition-colors hover:bg-accent/20 disabled:opacity-60"
>
<Play size={20} className="text-accent" />
<div className="flex-1">
<div className="font-semibold text-text">Vibe from &ldquo;{currentTrack.title}&rdquo;</div>
<div className="text-sm text-muted">{currentTrack.artist}</div>
<Play size={18} className="flex-none text-accent" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-text">Vibe from &ldquo;{currentTrack.title}&rdquo;</div>
<div className="truncate text-xs text-secondary">{currentTrack.artist}</div>
</div>
</button>
)}
<button
onClick={surpriseMe}
disabled={starting || libraryLoading || libraryTracks.length === 0}
className="flex w-full items-center gap-3 rounded-lg border border-border bg-surface0 p-4 text-left transition-colors hover:bg-surface1 disabled:opacity-60"
disabled={starting || libraryLoading || seedTracks.length === 0}
className="flex h-16 w-full items-center gap-3 rounded-lg border border-border bg-surface0/60 px-3 text-left transition-colors hover:bg-surface1 disabled:opacity-60"
>
<Shuffle size={20} className="text-text" />
<div className="flex-1">
<div className="font-semibold text-text">Surprise me</div>
<div className="text-sm text-muted">
<Shuffle size={18} className="flex-none text-text" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-text">Surprise me</div>
<div className="truncate text-xs text-secondary">
{libraryLoading
? 'Loading your library…'
: libraryTracks.length === 0
? 'No library tracks available to seed a vibe.'
: 'Start from a random track in your library.'}
? 'reading library…'
: seedTracks.length === 0
? 'No eligible tracks to seed from.'
: 'Random seed from the whole library.'}
</div>
</div>
{starting && <Loader2 size={18} className="animate-spin text-muted" />}
{starting && <Loader2 size={18} className="flex-none animate-spin text-muted" />}
</button>
</div>
{!libraryLoading && libraryTracks.length > 0 && (
{!libraryLoading && seedTracks.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-semibold text-muted">Or pick a seed track</h2>
<ul className="max-h-72 space-y-1 overflow-y-auto">
{libraryTracks.map((track, index) => (
<h2 className="text-[11px] font-semibold uppercase tracking-[0.1em] text-muted">Or pick a seed track</h2>
{/* Two columns of the sampled 50 one 320px-tall scroller showed 6
of them and left the rest behind a scrollbar. */}
<ul className="track-list grid rounded-lg border border-border bg-surface0/30 px-1 sm:grid-cols-2 sm:gap-x-4 sm:[&>*:nth-child(2)]:border-t-0">
{seedTracks.map((track, index) => (
<li key={track.id}>
<TrackRow
track={track}
queue={libraryTracks}
queue={seedTracks}
index={index}
showActions={false}
onSelect={startSession}
/>
</li>
))}
@@ -240,48 +185,56 @@ export default function Vibe() {
// ---- Active session ----
return (
<PageContainer width="sm" className="py-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles size={22} className="text-accent" />
<h1 className="text-2xl font-bold text-text">Vibing</h1>
{prefetching && <Loader2 size={16} className="animate-spin text-muted" />}
</div>
<button
onClick={handleEnd}
className="flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 text-sm text-text transition-colors hover:border-red-500/60 hover:text-red-300"
>
<Square size={14} />
End Vibe
</button>
</header>
<PageContainer width="sm" className="relative isolate py-6">
<VibeAura profile={profile} ambient />
<div className="relative z-10 space-y-6">
<PageHeader
title="Vibing"
// The buffer count belongs to Up next, and the plan revision is a
// debugging number. The live profile does belong here.
meta={profileMeta}
// Both verbs sit in one cluster. Dislike used to live a row down,
// which put 200px of empty header between the two things to press.
actions={
<>
{currentTrack && (
<button
onClick={handleDislike}
className="flex items-center gap-2 rounded-lg border border-border bg-surface0/60 px-3 py-2 text-sm text-text transition-colors hover:border-red/60 hover:text-red"
>
<ThumbsDown size={16} />
Dislike &amp; skip
</button>
)}
<button
onClick={handleEnd}
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-text transition-colors hover:border-red/60 hover:text-red"
>
<Square size={14} />
End Vibe
</button>
</>
}
/>
{empty && (
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
No recommendations came back for this seed yet. Try ending and starting a different vibe.
</div>
)}
{(empty || initialBatchStatus === 'exhausted' || initialBatchStatus === 'failed') && (
<div className="rounded-lg border border-border bg-surface0 p-4 text-sm text-muted">
{initialBatchStatus === 'failed'
? 'Could not load recommendations for this Vibe. Try starting a different one.'
: 'No recommendations came back for this seed yet. Try ending and starting a different vibe.'}
</div>
)}
{currentTrack && (
<div className="flex items-center gap-3">
<button
onClick={handleKeep}
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text transition-colors hover:border-pink-500/60 hover:text-pink-300"
>
<Heart size={16} />
Keep
</button>
<button
onClick={handleDislike}
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-text transition-colors hover:border-red-500/60 hover:text-red-300"
>
<ThumbsDown size={16} />
Dislike & skip
</button>
</div>
)}
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
{error}
</div>
)}
<VibeTimeline currentTrack={currentTrack} upcoming={upcoming} />
{/* Keep is gone: letting a track finish already reports `completed`,
which the director weighs the same as an explicit keep. */}
<VibeTimeline currentTrack={currentTrack} upcoming={upcoming} />
</div>
</PageContainer>
);
}
+29 -17
View File
@@ -2,22 +2,29 @@ import {
createRootRoute,
createRoute,
createRouter,
lazyRouteComponent,
} from '@tanstack/react-router';
import { z } from 'zod';
import AppShell from './components/AppShell';
import { LoadingState } from './components/LoadingState';
import Home from './pages/Home';
import Artists from './pages/Artists';
import ArtistDetail from './pages/ArtistDetail';
import Albums from './pages/Albums';
import AlbumDetail from './pages/AlbumDetail';
import Tracks from './pages/Tracks';
import Genres from './pages/Genres';
import Vibe from './pages/Vibe';
import Discover from './pages/Discover';
import Search from './pages/Search';
import Settings from './pages/Settings';
import Quarantine from './pages/Quarantine';
import Jobs from './pages/Jobs';
// Home is imported eagerly: it is the landing route, and deferring it would put
// a second round trip in front of the first paint. Every other page is a
// separate chunk, fetched when the listener first goes there and then held by
// the service worker.
const Artists = lazyRouteComponent(() => import('./pages/Artists'));
const ArtistDetail = lazyRouteComponent(() => import('./pages/ArtistDetail'));
const Albums = lazyRouteComponent(() => import('./pages/Albums'));
const AlbumDetail = lazyRouteComponent(() => import('./pages/AlbumDetail'));
const Tracks = lazyRouteComponent(() => import('./pages/Tracks'));
const Genres = lazyRouteComponent(() => import('./pages/Genres'));
const Vibe = lazyRouteComponent(() => import('./pages/Vibe'));
const Recommendations = lazyRouteComponent(() => import('./pages/Recommendations'));
const Search = lazyRouteComponent(() => import('./pages/Search'));
const Settings = lazyRouteComponent(() => import('./pages/Settings'));
const Quarantine = lazyRouteComponent(() => import('./pages/Quarantine'));
const Jobs = lazyRouteComponent(() => import('./pages/Jobs'));
// Root route renders the AppShell (NavRail + TopBar + PlaybackBar + NowPlayingPanel)
// with an <Outlet/> where the active child route renders.
@@ -73,10 +80,10 @@ export const vibeRoute = createRoute({
component: Vibe,
});
export const discoverRoute = createRoute({
export const recommendationsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/discover',
component: Discover,
path: '/recommendations',
component: Recommendations,
});
export const searchRoute = createRoute({
@@ -115,14 +122,19 @@ const routeTree = rootRoute.addChildren([
tracksRoute,
genresRoute,
vibeRoute,
discoverRoute,
recommendationsRoute,
searchRoute,
settingsRoute,
quarantineRoute,
jobsRoute,
]);
export const router = createRouter({ routeTree });
export const router = createRouter({
routeTree,
// Shown while a route's chunk is in flight. Without it the shell holds the
// previous page and the tap reads as a dropped input.
defaultPendingComponent: () => <LoadingState />,
});
// Type-safety: register the router instance type globally.
declare module '@tanstack/react-router' {
+41
View File
@@ -0,0 +1,41 @@
import api from './api';
/** One candidate and whatever became of it. `track_id` is null until acquired. */
export interface DiscoveryRow {
id: string;
source: string;
status: string;
title: string | null;
artist_credit: { name?: string; artist_id?: string }[] | null;
notes: { acquisition?: { query?: string; url?: string }; album?: string; seed_artist?: string; seed_title?: string } | null;
first_seen_at: string;
acquired_at: string | null;
last_error: string | null;
acquisition_attempts: number;
track_id: string | null;
track_title: string | null;
track_artist: string | null;
probation_status: 'probation' | 'retained' | 'retired' | null;
probation_entered_at: string | null;
completed_plays: number | null;
quick_skips: number | null;
}
export interface DiscoverySourceSummary {
source: string;
candidates: number;
probation: number;
retained: number;
retired: number;
stalled: number;
}
export const discoveryService = {
// GET /api/discovery/overview -> every candidate plus per-source totals
async overview(): Promise<{ rows: DiscoveryRow[]; summary: DiscoverySourceSummary[] }> {
const res = await api.get<{ rows: DiscoveryRow[]; summary: DiscoverySourceSummary[] }>(
'/discovery/overview'
);
return res.data;
},
};
+4 -2
View File
@@ -2,16 +2,18 @@ import api from './api';
import type { FeedbackAction, HistoryEntry } from '../types';
export const historyService = {
// POST /api/history { trackId, completed?, batchId? } -> { historyId }
// POST /api/history { trackId, completed?, batchId?, listenedMs? } -> { historyId }
async recordPlay(
trackId: string,
completed?: boolean,
batchId?: string
batchId?: string,
listenedMs?: number
): Promise<{ historyId: string }> {
const res = await api.post<{ historyId: string }>('/history', {
trackId,
completed,
batchId,
listenedMs,
});
return res.data;
},
+16
View File
@@ -1,6 +1,22 @@
// Barrel re-export for the library-related services. The previous version of this
// file pointed at `/library/*` paths, but the backend registers library routes at
// the `/api` root (see backend/src/app.ts). Use the per-entity services instead.
import api from './api';
export interface LibraryStats {
tracks: number;
albums: number;
artists: number;
/** Total playtime in seconds. */
duration: number;
}
// GET /api/library/stats
export async function fetchLibraryStats(): Promise<LibraryStats> {
const res = await api.get<LibraryStats>('/library/stats');
return res.data;
}
export { trackService } from './trackService';
export { artistService } from './artistService';
export { albumService } from './albumService';
+204
View File
@@ -0,0 +1,204 @@
import api from './api';
import type { Track } from '../types';
/**
* Client half of cross-device playback. One browser is one device, identified
* by a stored id so a reload keeps its place in the device list instead of
* adding a new row every time.
*/
const DEVICE_ID_KEY = 'muzick.deviceId';
/** Waits before reopening a push stream the server closed with an error. */
const STREAM_REOPEN_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000];
export type PlaybackCommand =
| { type: 'play' | 'pause' | 'next' | 'prev' }
| { type: 'seek'; position: number }
| { type: 'play_track'; trackId: string };
export interface PlaybackDevice {
id: string;
name: string;
lastSeenAt: string;
online: boolean;
isOwner: boolean;
}
export interface PlaybackSnapshot {
deviceId: string | null;
trackId: string | null;
/** The Vibe session the owning device is driving, if it is driving one. */
vibeSessionId: string | null;
queue: Track[];
queueIndex: number;
position: number;
isPlaying: boolean;
version: number;
updatedAt: string;
}
export type PlaybackSyncEvent =
| { type: 'state'; state: PlaybackSnapshot; devices: PlaybackDevice[] }
| { type: 'command'; deviceId: string; command: PlaybackCommand };
/**
* The id this tab registered under, or the one the browser last used.
*
* Two levels, because a device is really a tab and not a browser: the audio
* element, the queue and the command handling all live in one page. Two tabs
* sharing a single id are one device that runs every command twice, so each tab
* keeps its own id in `sessionStorage` which survives its reloads and nothing
* else. The `localStorage` copy is only a seed for a tab that has no id yet, and
* the server refuses to hand it back while another tab's stream is holding it.
*/
export function storedDeviceId(): string | null {
try {
return sessionStorage.getItem(DEVICE_ID_KEY) ?? localStorage.getItem(DEVICE_ID_KEY);
} catch {
return null;
}
}
function rememberDeviceId(id: string): void {
try {
sessionStorage.setItem(DEVICE_ID_KEY, id);
localStorage.setItem(DEVICE_ID_KEY, id);
} catch {
// Private browsing: the device still works, it just re-registers next load.
}
}
/**
* A name the listener can tell apart in a device menu. The user agent is the
* only thing a browser will say about its host, so this reads platform and
* browser out of it rather than showing the raw string.
*/
export function describeThisDevice(): string {
const ua = navigator.userAgent;
const platform = /iPhone|iPad|iPod/.test(ua) ? 'iPhone'
: /Android/.test(ua) ? 'Android'
: /Macintosh/.test(ua) ? 'Mac'
: /Windows/.test(ua) ? 'Windows'
: /Linux/.test(ua) ? 'Linux'
: 'Device';
const browser = /Firefox\//.test(ua) ? 'Firefox'
: /Edg\//.test(ua) ? 'Edge'
: /Chrome\//.test(ua) ? 'Chrome'
: /Safari\//.test(ua) ? 'Safari'
: 'Browser';
return `${platform} · ${browser}`;
}
export const playbackSyncService = {
async register(): Promise<PlaybackDevice> {
const res = await api.post<PlaybackDevice>('/playback/devices', {
deviceId: storedDeviceId(),
name: describeThisDevice(),
});
rememberDeviceId(res.data.id);
return res.data;
},
async getState(): Promise<{ state: PlaybackSnapshot; devices: PlaybackDevice[] }> {
const res = await api.get<{ state: PlaybackSnapshot; devices: PlaybackDevice[] }>('/playback/state');
return res.data;
},
/** Report what this device is playing. Rejected with 409 once it is not the owner. */
async reportState(deviceId: string, patch: {
trackId?: string | null;
vibeSessionId?: string | null;
queue?: Track[];
queueIndex?: number;
position?: number;
isPlaying?: boolean;
}): Promise<void> {
await api.post('/playback/state', { deviceId, ...patch });
},
async sendCommand(command: PlaybackCommand): Promise<void> {
await api.post('/playback/command', command);
},
async transfer(deviceId: string): Promise<void> {
await api.post('/playback/transfer', { deviceId });
},
async release(deviceId: string): Promise<void> {
await api.post('/playback/release', { deviceId });
},
/**
* Open the push channel and keep it open. The server resends the full
* snapshot on every connect, so a reconnection needs no resume bookkeeping.
*
* EventSource retries a dropped connection by itself, but only that case: an
* error status from the server a backend restart, a proxy 502, a captive
* portal answering for it closes the stream for good. A phone that hit one
* of those stayed silent until the page was reloaded, so reopen it here, and
* check the moment the network or the tab comes back rather than waiting out
* a backoff the listener is watching.
*/
openStream(deviceId: string, onEvent: (event: PlaybackSyncEvent) => void): () => void {
const base = (import.meta.env.VITE_API_URL as string | undefined) || '/api';
const url = `${base}/playback/stream?deviceId=${encodeURIComponent(deviceId)}`;
let source: EventSource | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
let attempt = 0;
let closed = false;
const open = () => {
if (closed) return;
clearTimeout(timer);
source = new EventSource(url);
source.onopen = () => { attempt = 0; };
source.onmessage = (message) => {
try {
onEvent(JSON.parse(message.data) as PlaybackSyncEvent);
} catch {
// A malformed frame is not worth tearing the stream down for.
}
};
source.onerror = () => {
// CONNECTING means EventSource is already retrying on its own terms.
if (closed || source?.readyState !== EventSource.CLOSED) return;
source.close();
source = null;
const wait = STREAM_REOPEN_MS[Math.min(attempt, STREAM_REOPEN_MS.length - 1)];
attempt += 1;
timer = setTimeout(open, wait);
};
};
const reopenIfDead = () => {
if (closed || document.visibilityState === 'hidden') return;
if (!source || source.readyState === EventSource.CLOSED) open();
};
// React never unmounts on a page leaving, so the stream would stay open
// until the server noticed the socket die. Closing it here frees the device
// id straight away, which is what lets a reload register as the same device
// rather than being told it is a second tab. A page that comes back from the
// history cache reopens through `reopenIfDead`.
const closeForNow = () => {
clearTimeout(timer);
source?.close();
source = null;
};
open();
window.addEventListener('online', reopenIfDead);
window.addEventListener('pagehide', closeForNow);
document.addEventListener('visibilitychange', reopenIfDead);
return () => {
closed = true;
clearTimeout(timer);
window.removeEventListener('online', reopenIfDead);
window.removeEventListener('pagehide', closeForNow);
document.removeEventListener('visibilitychange', reopenIfDead);
source?.close();
};
},
};
+6
View File
@@ -16,6 +16,12 @@ export const trackService = {
return res.data;
},
// GET /api/tracks/seeds — a random playable sample and the size of the pool.
async listSeedTracks(limit = 50): Promise<{ total: number; tracks: Track[] }> {
const res = await api.get<{ total: number; tracks: Track[] }>('/tracks/seeds', { params: { limit } });
return res.data;
},
// GET /api/tracks/:id
async getTrack(id: string): Promise<Track> {
const res = await api.get<Track>(`/tracks/${id}`);
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() }));
vi.mock('./api', () => ({ default: { post, get } }));
import { vibeService } from './vibeService';
describe('durable vibe service', () => {
it('serves the next item with the caller plan version', async () => {
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 3, now: null, preview: [] } });
await vibeService.next('session', 3);
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/advance', { expectedPlanVersion: 3 });
});
it('uses an explicit idempotency key to advance a served but unplayable item', async () => {
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 3, now: null, preview: [] } });
await vibeService.advancePastUnplayable('session', 3, {
eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track',
});
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/advance', {
expectedPlanVersion: 3,
unplayable: { eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track' },
});
});
it('sends client event ids to the durable event ledger', async () => {
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 2, now: null, preview: [] } });
await vibeService.event('session', {
eventId: 'event', type: 'progress', occurredAt: '2026-01-01T00:00:00.000Z', trackId: 'track', positionMs: 30000,
});
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/events', expect.objectContaining({
eventId: 'event', type: 'progress', trackId: 'track', positionMs: 30000,
}));
});
});
+97 -49
View File
@@ -1,73 +1,121 @@
import api from './api';
import type { Track } from '../types';
// A candidate from the v2 recommendation plan. The plan is stored server-side
// in Redis; the frontend only needs trackId + explanation for display.
// These types intentionally mirror the durable session API. Tracks are not
// embedded in a plan revision: the client resolves ids through the normal
// library endpoint so a deleted/hidden track can never become playable merely
// because an older plan mentioned it.
export interface VibePlanItem {
trackId: string;
generatorId: string;
explanation: unknown[];
relevance: number;
plan_version_id: string;
ordinal: number;
track_id: string;
slot_role: string | null;
candidate_source: string;
score: number;
score_breakdown: Record<string, unknown>;
explanation: unknown;
committed: boolean;
}
export interface VibeStartResponse {
export interface DurableVibeSessionResponse {
sessionId: string;
plan: VibePlanItem[];
/** The durable session row. Present on start, resume and plan reads. */
session?: { id: string; seed_track_id: string | null };
planVersion: number | null;
now: VibePlanItem | null;
preview: VibePlanItem[];
state: Record<string, unknown>;
replanned: boolean;
replanReason: string | null;
}
export interface VibeNextResponse {
track: Track;
explanation: unknown[] | null;
planRemaining: number;
export type VibeEventType =
| 'playback_started'
| 'progress'
| 'completed'
| 'skipped'
| 'disliked'
| 'kept';
export interface VibeEventInput {
eventId: string;
type: VibeEventType;
trackId?: string;
occurredAt: string;
positionMs?: number;
durationMs?: number;
payload?: Record<string, unknown>;
}
export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked';
export interface VibeEventResponse extends DurableVibeSessionResponse {
event: { id: string; client_event_id: string | null; type: string };
idempotent: boolean;
}
/** Coarse local calendar context, used only for short-lived Vibe preferences. */
export interface VibeCalendarContext {
localHour: number;
weekday: number;
month: number;
timeZone?: string;
}
/** A durable, idempotent advancement past a plan item the player cannot load. */
export interface VibeUnplayableItemInput {
eventId: string;
planVersionId: string;
ordinal: number;
trackId: string;
}
// V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and
// serves tracks one at a time via GET /next. Feedback triggers replanning.
export const vibeService = {
// POST /api/v2/vibe/start { seedTrackId? } -> { sessionId, plan }
async start(seedTrackId?: string): Promise<VibeStartResponse> {
const res = await api.post<VibeStartResponse>('/v2/vibe/start', { seedTrackId });
async start(seedTrackId?: string, context?: VibeCalendarContext): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId, context });
return res.data;
},
// GET /api/v2/vibe/next -> { track, explanation, planRemaining }
// Returns one track at a time, shifting the server-side plan.
// 404 if no active plan — caller should handle gracefully.
async next(): Promise<VibeNextResponse> {
const res = await api.get<VibeNextResponse>('/v2/vibe/next');
/**
* Pick up a session that is already running, on a device that did not start
* it. Resuming is how Vibe control follows the audio between devices; it also
* revives a session the reaper had paused.
*/
async resume(sessionId: string): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { resumeSessionId: sessionId });
return res.data;
},
// POST /api/v2/vibe/feedback { trackId, action } -> { status, planRemaining }
// Action 'promoted' also calls addFavorite; 'disliked' also calls dislikeTrack.
// Triggers replan of the remaining plan.
async feedback(trackId: string, action: VibeFeedbackAction): Promise<{ status: string; planRemaining: number }> {
const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action });
async getPlan(sessionId: string, version?: number): Promise<DurableVibeSessionResponse> {
const res = await api.get<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/plans`, {
params: version === undefined ? undefined : { version },
});
return res.data;
},
// GET /api/v2/vibe/plan -> { sessionId, planRemaining, plan }
// Debug endpoint — returns the full remaining plan.
async getPlan(): Promise<{ sessionId: string; planRemaining: number; plan: VibePlanItem[] }> {
const res = await api.get('/v2/vibe/plan');
async next(sessionId: string, expectedPlanVersion: number): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/advance`, {
expectedPlanVersion,
});
return res.data;
},
async advancePastUnplayable(
sessionId: string,
expectedPlanVersion: number,
unplayable: VibeUnplayableItemInput,
): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/advance`, {
expectedPlanVersion,
unplayable,
});
return res.data;
},
async event(sessionId: string, event: VibeEventInput): Promise<VibeEventResponse> {
const res = await api.post<VibeEventResponse>(`/v2/vibe/sessions/${sessionId}/events`, event);
return res.data;
},
async end(sessionId: string): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/end`);
return res.data;
},
};
// Fetch N tracks from the v2 plan sequentially. Each call to /next shifts the
// server-side plan, so calls must be sequential (not parallel). Stops early on
// 404 (plan exhausted or expired).
export async function fetchNextBatch(count: number): Promise<Track[]> {
const tracks: Track[] = [];
for (let i = 0; i < count; i++) {
try {
const { track } = await vibeService.next();
tracks.push(track);
} catch {
break;
}
}
return tracks;
}
+438
View File
@@ -0,0 +1,438 @@
import { AxiosError } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
const { start, resume, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
start: vi.fn(), resume: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
}));
vi.mock('./vibeService', () => ({ vibeService: { start, resume, next, advancePastUnplayable, event, end } }));
vi.mock('./trackService', () => ({ trackService: { getTrack } }));
import {
adoptVibeSession,
advancePastUnplayableVibeTrack,
advanceVibe,
endVibeSession,
releaseVibeDriving,
reportVibeEvent,
startVibeSession,
} from './vibeSession';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
album_id: 'album', duration: 180, state: 'LIBRARY', source_type: 'MANUAL',
play_count: 0, skip_count: 0, dislike_count: 0,
});
const item = (track_id: string, committed = false, ordinal = 0) => ({
plan_version_id: 'plan', ordinal, track_id, slot_role: null, candidate_source: 'test',
score: 1, score_breakdown: {}, explanation: [], committed,
});
const response = (planVersion: number, now: ReturnType<typeof item> | null = item('one', true), preview = [item('two')]) => ({
sessionId: 'session-a', planVersion, now, preview, state: {}, replanned: false, replanReason: null,
});
describe('durable Vibe session client', () => {
beforeEach(() => {
vi.clearAllMocks();
useVibeStore.getState().reset();
usePlaybackStore.setState({
currentTrack: null, queue: [], currentIndex: -1, isPlaying: false, vibeAdvanceHandler: null, queueOwner: 'ordinary',
});
getTrack.mockImplementation((id: string) => Promise.resolve(track(id)));
});
it('starts by version-serving and hydrating the first durable plan item', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await expect(startVibeSession(track('one'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] });
expect(start).toHaveBeenCalledWith('one', expect.objectContaining({
localHour: expect.any(Number), weekday: expect.any(Number), month: expect.any(Number),
}));
expect(next).toHaveBeenCalledWith('session-a', 1);
expect(useVibeStore.getState()).toMatchObject({ activeSessionId: 'session-a', planVersion: 1, buffer: [track('two')] });
expect(usePlaybackStore.getState().currentTrack).toEqual(track('one'));
});
it('drops a second recording of a song already in the queue', async () => {
// Same title, different track: a cover or another artist's version. One sitting
// should not play the same song twice.
getTrack.mockImplementation((id: string) =>
Promise.resolve({ ...track(id), title: id === 'cover' ? 'One' : track(id).title })
);
start.mockResolvedValue(response(1, item('one'), [item('cover'), item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('cover'), item('two')]));
await startVibeSession({ ...track('one'), title: 'one' });
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two']);
});
it('replans, version-serves, and removes stale prefetched tracks before advancing', async () => {
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
next
.mockResolvedValueOnce(response(1, item('one', true), [item('stale')]))
.mockResolvedValueOnce(response(2, item('two', true), [item('three')]));
event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
await startVibeSession(track('one'));
await advanceVibe('skipped');
expect(next).toHaveBeenLastCalledWith('session-a', 2);
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
expect(useVibeStore.getState().buffer.map((entry) => entry.id)).toEqual(['three']);
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('stale');
expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one', eventId: expect.any(String) }));
});
it('replaces only the future when a keep event replans', async () => {
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
event.mockResolvedValue({ ...response(2, item('fresh'), [item('fresh'), item('later')]), replanned: true, event: {}, idempotent: false });
await startVibeSession(track('one'));
await reportVibeEvent('kept', 'one');
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']);
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
expect(useVibeStore.getState().planVersion).toBe(2);
});
it('reconciles the canonical replacement returned by an idempotent material-event retry', async () => {
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
// The first response was lost after it published revision 2. Retrying the
// same client event returns that revision with replanned=false.
event.mockResolvedValue({
...response(2, item('fresh'), [item('fresh'), item('later')]),
replanned: false,
event: {},
idempotent: true,
});
await startVibeSession(track('one'));
await reportVibeEvent('kept', 'one');
expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh'), track('later')] });
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']);
});
it('cleans up local playback when the durable session is gone', async () => {
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
event.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
}));
await startVibeSession(track('one'));
await expect(reportVibeEvent('kept', 'one')).rejects.toThrow('gone');
expect(useVibeStore.getState().activeSessionId).toBeNull();
expect(usePlaybackStore.getState()).toMatchObject({ currentTrack: null, queue: [], isPlaying: false });
});
it('hands ordinary playback back to browse queues without Vibe reporting or next interception', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await startVibeSession(track('one'));
const ordinary = track('ordinary');
const playback = usePlaybackStore.getState();
playback.setQueue([ordinary, track('ordinary-next')]);
playback.playTrack(ordinary);
playback.nextWithReason('completed');
expect(usePlaybackStore.getState()).toMatchObject({
queueOwner: 'ordinary', currentTrack: track('ordinary-next'), vibeAdvanceHandler: null,
});
expect(event).not.toHaveBeenCalled();
});
it('serializes material events and ignores an older plan revision', async () => {
start.mockResolvedValue(response(1, item('one'), [item('old')]));
next.mockResolvedValue(response(1, item('one', true), [item('old')]));
await startVibeSession(track('one'));
let resolveFirst!: (value: ReturnType<typeof response> & { event: object; idempotent: boolean }) => void;
event.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }));
event.mockResolvedValueOnce({ ...response(1, item('stale'), [item('stale')]), replanned: true, event: {}, idempotent: false });
const first = reportVibeEvent('kept', 'one');
const second = reportVibeEvent('completed', 'one');
await Promise.resolve();
expect(event).toHaveBeenCalledTimes(1);
resolveFirst({ ...response(2, item('fresh'), [item('fresh')]), replanned: true, event: {}, idempotent: false });
await Promise.all([first, second]);
expect(event).toHaveBeenCalledTimes(2);
expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh')] });
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh']);
});
it('retries a failed event with the same idempotency key until it is acknowledged', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
event.mockRejectedValueOnce(new Error('network dropped')).mockResolvedValueOnce({
...response(1), event: {}, idempotent: true,
});
await startVibeSession(track('one'));
await reportVibeEvent('progress', 'one', 30000, 180000);
expect(event).toHaveBeenCalledTimes(2);
expect(event.mock.calls[0][1].eventId).toBe(event.mock.calls[1][1].eventId);
});
it('skips a hidden plan item and starts from the next playable item', async () => {
start.mockResolvedValue(response(1, item('hidden'), [item('good')]));
next
.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')]));
advancePastUnplayable.mockResolvedValueOnce(response(1, item('good', true), [item('later')]));
getTrack.mockImplementation((id: string) => id === 'hidden'
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
: Promise.resolve(track(id)));
await expect(startVibeSession(track('good'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] });
expect(next).toHaveBeenCalledTimes(1);
expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
planVersionId: 'plan', ordinal: 0, trackId: 'hidden', eventId: expect.any(String),
}));
expect(usePlaybackStore.getState().currentTrack?.id).toBe('good');
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('hidden');
});
it('advances consecutive hidden replacements directly without replaying an older served cursor', async () => {
start.mockResolvedValue(response(1, item('hidden-one'), [item('hidden-two', false, 1)]));
next.mockResolvedValueOnce(response(1, item('hidden-one', true), [item('hidden-two', false, 1)]));
advancePastUnplayable
.mockResolvedValueOnce(response(1, item('hidden-two', true, 1), [item('good', false, 2)]))
.mockResolvedValueOnce(response(1, item('good', true, 2), [item('later', false, 3)]));
getTrack.mockImplementation((id: string) => ['hidden-one', 'hidden-two'].includes(id)
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
: Promise.resolve(track(id)));
await expect(startVibeSession(track('good'))).resolves.toMatchObject({
status: 'complete', tracks: [track('good'), track('later')],
});
expect(next).toHaveBeenCalledTimes(1);
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
expect(advancePastUnplayable.mock.calls.map(([, , input]) => [input.ordinal, input.trackId]))
.toEqual([[0, 'hidden-one'], [1, 'hidden-two']]);
expect(usePlaybackStore.getState().currentTrack?.id).toBe('good');
});
it('retries an unplayable advancement with its original event id after a lost response', async () => {
start.mockResolvedValue(response(1, item('hidden'), [item('good')]));
next.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')]));
advancePastUnplayable
.mockRejectedValueOnce(new Error('response dropped'))
.mockResolvedValueOnce(response(1, item('good', true), [item('later')]));
getTrack.mockImplementation((id: string) => id === 'hidden'
? Promise.resolve({ ...track(id), state: 'MISSING' })
: Promise.resolve(track(id)));
await startVibeSession(track('good'));
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
expect(advancePastUnplayable.mock.calls[0][2].eventId)
.toBe(advancePastUnplayable.mock.calls[1][2].eventId);
});
it('advances a stream-error track through its stored durable cursor without ordinary feedback', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two', false, 1)]));
next.mockResolvedValueOnce(response(1, item('one', true), [item('two', false, 1)]));
advancePastUnplayable.mockResolvedValueOnce(response(1, item('two', true, 1), [item('later', false, 2)]));
await startVibeSession(track('one'));
await advancePastUnplayableVibeTrack('one');
expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
planVersionId: 'plan', ordinal: 0, trackId: 'one', eventId: expect.any(String),
}));
expect(event).not.toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' }));
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
expect(useVibeStore.getState().currentPlanItem).toMatchObject({ track_id: 'two', ordinal: 1 });
});
// A locked phone drops its connection for a moment. Neither half of the
// advance may treat that as the session's fault: the plan is still valid, the
// prefetched future is still worth keeping, and the listener's action must
// reach the director exactly once.
const offline = () => new AxiosError('network error', 'ERR_NETWORK');
/** Let the pending backoff hear the network come back, rather than waiting it out. */
const comeBackOnline = async () => {
await Promise.resolve();
window.dispatchEvent(new Event('online'));
};
it('waits out a dropped connection and sends the same event once', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next
.mockResolvedValueOnce(response(1, item('one', true), [item('two')]))
.mockResolvedValue(response(2, item('two', true), [item('three')]));
await startVibeSession(track('one'));
// Fails, retries immediately, fails again, then waits for the network.
event
.mockRejectedValueOnce(offline())
.mockRejectedValueOnce(offline())
.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
const advancing = advanceVibe('completed');
await vi.waitFor(() => expect(event).toHaveBeenCalledTimes(2));
await comeBackOnline();
await advancing;
const eventIds = event.mock.calls.map((call) => (call[1] as { eventId: string }).eventId);
expect(new Set(eventIds).size).toBe(1);
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
});
it('retries only the serve when the connection drops after the event landed', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next
.mockResolvedValueOnce(response(1, item('one', true), [item('two')]))
.mockRejectedValueOnce(offline())
.mockResolvedValue(response(2, item('two', true), [item('three')]));
event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
await startVibeSession(track('one'));
const advancing = advanceVibe('completed');
await vi.waitFor(() => expect(next).toHaveBeenCalledTimes(2));
await comeBackOnline();
await advancing;
// The event is the listener's action and must not be replayed by a retry
// that only the serve needed.
expect(event).toHaveBeenCalledTimes(1);
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
});
it('keeps the prefetched future when a dropped connection outlives every retry', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await startVibeSession(track('one'));
const future = usePlaybackStore.getState().queue.map((entry) => entry.id);
event.mockRejectedValue(new AxiosError('bad gateway', undefined, undefined, undefined, {
data: {}, status: 502, statusText: 'Bad Gateway', headers: {}, config: {} as never,
}));
// Cut every backoff short so the retries exhaust without real waiting.
const exhausting = advanceVibe('completed');
const pump = setInterval(() => window.dispatchEvent(new Event('online')), 0);
await expect(exhausting).rejects.toThrow('bad gateway');
clearInterval(pump);
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(future);
expect(usePlaybackStore.getState().isPlaying).toBe(false);
});
it('plays the seed first and steps off it without reporting plan feedback', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await startVibeSession(track('seed'));
expect(usePlaybackStore.getState().currentTrack?.id).toBe('seed');
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['seed', 'one', 'two']);
await advanceVibe('completed');
// The seed is not a plan item: no feedback, no second serve, and the plan's
// own first item is what plays next.
expect(event).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
});
it('adopts a session running on another device and drives it from here', async () => {
// What a device that has just been handed the audio starts from: the queue
// and playing track came off the playback snapshot, the session did not.
usePlaybackStore.setState({
currentTrack: track('one'), queue: [track('one')], currentIndex: 0, isPlaying: true,
});
resume.mockResolvedValue({ ...response(3, null, [item('two'), item('three')]), session: { id: 'session-a', seed_track_id: 'one' } });
await expect(adoptVibeSession('session-a')).resolves.toBe(true);
expect(resume).toHaveBeenCalledWith('session-a');
expect(useVibeStore.getState()).toMatchObject({
activeSessionId: 'session-a', seedTrackId: 'one', planVersion: 3, buffer: [track('two'), track('three')],
});
// No cursor is recoverable for the track already playing; the next advance sets one.
expect(useVibeStore.getState().currentPlanItem).toBeNull();
expect(usePlaybackStore.getState().queueOwner).toBe('vibe');
expect(usePlaybackStore.getState().vibeAdvanceHandler).not.toBeNull();
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
// The track that was playing keeps playing: adoption replaces the future only.
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
});
it('keeps playing the ordinary queue when the session it was told about is gone', async () => {
usePlaybackStore.setState({ currentTrack: track('one'), queue: [track('one')], currentIndex: 0 });
resume.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
}));
await expect(adoptVibeSession('session-a')).resolves.toBe(false);
expect(usePlaybackStore.getState()).toMatchObject({ queueOwner: 'ordinary', vibeAdvanceHandler: null });
expect(useVibeStore.getState().activeSessionId).toBeNull();
});
it('stops driving a session without ending it when the audio moves away', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
await startVibeSession(track('one'));
releaseVibeDriving();
expect(usePlaybackStore.getState().vibeAdvanceHandler).toBeNull();
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
expect(end).not.toHaveBeenCalled();
});
it('reports an unplayable stream as a skip while an adopted session has no cursor', async () => {
usePlaybackStore.setState({
currentTrack: track('one'), queue: [track('one')], currentIndex: 0,
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
});
resume.mockResolvedValue(response(3, null, [item('two')]));
await adoptVibeSession('session-a');
event.mockResolvedValue(response(4, null, [item('two')]));
next.mockResolvedValue(response(4, item('two', true), []));
await advancePastUnplayableVibeTrack('one');
expect(advancePastUnplayable).not.toHaveBeenCalled();
expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' }));
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
});
it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => {
start.mockResolvedValue(response(1, item('one'), [item('two')]));
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
end.mockResolvedValue(response(1));
await startVibeSession(track('one'));
await endVibeSession();
expect(end).toHaveBeenCalledWith('session-a');
expect(useVibeStore.getState().activeSessionId).toBeNull();
expect(usePlaybackStore.getState()).toMatchObject({
queueOwner: 'ordinary', vibeAdvanceHandler: null, currentTrack: null, queue: [], isPlaying: false,
});
});
});
+664
View File
@@ -0,0 +1,664 @@
import axios from 'axios';
import type { Track } from '../types';
import { usePlaybackStore, type VibeAdvanceReason } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import {
vibeService,
type DurableVibeSessionResponse,
type VibeCalendarContext,
type VibeEventType,
type VibePlanItem,
} from './vibeService';
import { trackService } from './trackService';
export interface StartedVibeSession {
status: 'complete' | 'exhausted' | 'failed';
tracks: Track[];
}
let startInFlight: Promise<StartedVibeSession> | null = null;
let advanceInFlight: Promise<void> | null = null;
let materialTail: Promise<void> = Promise.resolve();
// A seeded Vibe plays its seed first — asking for a vibe "from this track" and
// getting a different track is the surprise. The seed sits in front of the
// durable plan without being part of it, so the first advance must consume it
// locally instead of reporting feedback and serving the next item.
// ponytail: no 'completed' event is sent for the seed. The listener chose it
// explicitly; the director already has that signal from the session's seed id.
let seedPendingTrackId: string | null = null;
interface PendingEvent {
sessionId: string;
input: Parameters<typeof vibeService.event>[1];
retried: boolean;
settled: boolean;
/** Consecutive failures that were the network's fault rather than the server's. */
transientAttempts: number;
resolve: (response: DurableVibeSessionResponse) => void;
reject: (error: unknown) => void;
}
// A phone with a locked screen drops its radio, changes network, or dozes, and
// one request fails. Waiting through it is right: the plan is still valid and
// the event id is stable, so the same event is simply sent again. Roughly two
// minutes of waiting in total before giving up on the listener's behalf.
const TRANSIENT_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 20_000, 30_000, 30_000, 30_000];
// The event ledger deduplicates client_event_id. Keep an event in this ordered
// outbox until the server acknowledges it so a transient failure never turns a
// retry into a second listener action.
const eventOutbox: PendingEvent[] = [];
let flushingOutbox = false;
function serializeMaterial<T>(operation: () => Promise<T>): Promise<T> {
const result = materialTail.then(operation, operation);
materialTail = result.then(() => undefined, () => undefined);
return result;
}
function newEventId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
// UUID v4-shaped fallback for older embedded webviews. The server only uses
// this as an idempotency key, not as a source of entropy.
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (letter) => {
const value = Math.floor(Math.random() * 16);
return (letter === 'x' ? value : (value & 0x3) | 0x8).toString(16);
});
}
function localCalendarContext(): VibeCalendarContext {
const now = new Date();
let timeZone: string | undefined;
try {
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
} catch {
// Some embedded players omit Intl time-zone support. The coarse calendar
// fields still provide useful, non-identifying context.
}
return {
localHour: now.getHours(),
weekday: now.getDay(),
month: now.getMonth() + 1,
timeZone,
};
}
function isPlayable(track: Track): boolean {
return !['HIDDEN', 'MISSING', 'DELETED'].includes(track.state);
}
async function hydrateItem(item: VibePlanItem | null): Promise<Track | null> {
if (!item) return null;
try {
const track = await trackService.getTrack(item.track_id);
return isPlayable(track) ? track : null;
} catch {
// A plan can outlive a hidden/deleted file. Never substitute another item
// for this ordinal: keeping the remaining order is safer than a mismatch.
return null;
}
}
async function hydratePreview(items: VibePlanItem[]): Promise<Track[]> {
const uniqueIds = [...new Set(items.map((item) => item.track_id))];
const loaded = await Promise.all(uniqueIds.map(async (id) => {
try {
const track = await trackService.getTrack(id);
return [id, isPlayable(track) ? track : null] as const;
} catch {
return [id, null] as const;
}
}));
const byId = new Map(loaded.filter((entry): entry is readonly [string, Track] => entry[1] !== null));
return items.flatMap((item) => {
const track = byId.get(item.track_id);
return track ? [track] : [];
});
}
/**
* Two recordings of one song a cover, a remaster, another artist's version
* are distinct track ids but read as a duplicate in one sitting. The title is the
* key; remixes and live cuts name themselves in the title, so they survive.
* ponytail: title string match, no normalisation beyond case and edges. Add
* feat./punctuation stripping only if real duplicates keep getting through.
*/
const songKey = (track: Track) => (track.title || track.id).trim().toLowerCase();
function dedupeSongs(tracks: Track[], seen = new Set<string>()): Track[] {
return tracks.filter((track) => {
const key = songKey(track);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** Replace only the queue after the currently playing Vibe track. */
function replaceUnplayedQueue(preview: Track[]): void {
const playback = usePlaybackStore.getState();
const current = playback.currentTrack;
const queueIndex = current
? (playback.currentIndex >= 0 && playback.queue[playback.currentIndex]?.id === current.id
? playback.currentIndex
: playback.queue.findIndex((track) => track.id === current.id))
: -1;
const history = queueIndex >= 0
? playback.queue.slice(0, queueIndex + 1)
: current ? [current] : [];
// While the seed plays, the durable cursor's own track sits between it and the
// preview. A `preview` list never contains that served item, so keep it — but
// only while it is still the cursor, never after it has been retired.
const next = playback.queue[queueIndex + 1];
const served = queueIndex >= 0
&& playback.queue[queueIndex]?.id === seedPendingTrackId
&& next
&& useVibeStore.getState().currentPlanItem?.track_id === next.id
? [next]
: [];
const future = dedupeSongs(preview, new Set([...history, ...served].map(songKey)));
playback.setVibeQueue([...history, ...served, ...future]);
}
function isCurrentVibeOwner(sessionId: string): boolean {
return useVibeStore.getState().activeSessionId === sessionId
&& usePlaybackStore.getState().queueOwner === 'vibe';
}
async function reconcilePreview(sessionId: string, response: DurableVibeSessionResponse): Promise<Track[]> {
if (!isCurrentVibeOwner(sessionId)) return [];
const preview = await hydratePreview(response.preview);
if (!isCurrentVibeOwner(sessionId) || !useVibeStore.getState().setPlan(response.planVersion, preview)) return [];
useVibeStore.getState().setProfile(response.state);
replaceUnplayedQueue(preview);
return preview;
}
async function serveNextCurrent(sessionId: string, version: number): Promise<DurableVibeSessionResponse> {
// A concurrent device or a feedback replan can make a version stale between
// the event response and /next. A stale response has an uncommitted `now`;
// refresh once with its latest version before admitting a track to playback.
let response = await vibeService.next(sessionId, version);
if (response.now?.committed) return response;
if (!response.planVersion || response.planVersion === version) return response;
response = await vibeService.next(sessionId, response.planVersion);
return response;
}
async function serveNextPlayable(
sessionId: string,
version: number,
): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> {
return resolvePlayableResponse(sessionId, await serveNextCurrent(sessionId, version));
}
/**
* Serving a version is idempotent, so a dropped connection here costs nothing
* but the wait. The feedback event has already been acknowledged by this point,
* which is why the retry sits around the serve alone: replaying the whole
* advance would send a second event for a track the listener heard once.
*/
async function serveNextPlayableRetrying(
sessionId: string,
version: number,
): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> {
for (let attempt = 0; ; attempt++) {
try {
return await serveNextPlayable(sessionId, version);
} catch (error) {
if (!isTransientEventError(error) || attempt >= TRANSIENT_BACKOFF_MS.length - 1) throw error;
await waitBeforeRetry(attempt);
}
}
}
async function advanceResponsePastUnplayable(
sessionId: string,
response: DurableVibeSessionResponse,
): Promise<DurableVibeSessionResponse> {
if (!response.now?.committed || !response.planVersion) return response;
const unplayable = {
eventId: newEventId(),
planVersionId: response.now.plan_version_id,
ordinal: response.now.ordinal,
trackId: response.now.track_id,
};
try {
return await vibeService.advancePastUnplayable(sessionId, response.planVersion, unplayable);
} catch (error) {
// A response may have been lost after the server committed the advance.
// Retry the same event id so it returns the same replacement rather than
// consuming another future item.
if (isSessionTerminalError(error)) throw error;
return vibeService.advancePastUnplayable(sessionId, response.planVersion, unplayable);
}
}
async function resolvePlayableResponse(
sessionId: string,
initialResponse: DurableVibeSessionResponse,
): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> {
let response = initialResponse;
// A durable plan can reference a file which has since become hidden. Commit
// past such entries but never load one into the player. An unplayable
// advancement already returns and commits its replacement, so process that
// response directly: asking ordinary /next again would replay the original
// served cursor rather than advancing through consecutive hidden entries.
for (let attempts = 0; attempts < 20; attempts++) {
if (!response.now?.committed || !response.planVersion) {
if (!response.planVersion) return null;
response = await serveNextCurrent(sessionId, response.planVersion);
continue;
}
let now = await hydrateItem(response.now);
let preview = await hydratePreview(response.preview);
if (now) return { response, now, preview };
const advanced = await advanceResponsePastUnplayable(sessionId, response);
if (!advanced.planVersion) return null;
// The unplayable transition may itself race a feedback replan. Its stale
// response did not advance the old revision, so version-serve the current
// revision normally rather than treating a preview item as committed.
if (!advanced.now?.committed) {
response = await serveNextCurrent(sessionId, advanced.planVersion);
continue;
}
response = advanced;
}
return null;
}
function deactivateBrokenSession(): void {
seedPendingTrackId = null;
const playback = usePlaybackStore.getState();
playback.setVibeAdvanceHandler(null);
useVibeStore.getState().reset();
playback.pause();
playback.setQueue([]);
playback.setCurrentTrack(null);
}
function isSessionTerminalError(error: unknown): boolean {
return axios.isAxiosError(error) && [401, 404, 409].includes(error.response?.status ?? 0);
}
export function vibeErrorMessage(error: unknown): string {
if (!axios.isAxiosError(error)) return 'Could not refresh this Vibe. Please try again.';
switch (error.response?.status) {
case 400: return 'Vibe needs a valid user identity.';
case 404: return 'This Vibe session is no longer available.';
case 409: return 'This Vibe session has already ended or was replaced.';
default: return 'Could not refresh this Vibe. Please try again.';
}
}
async function sendEvent(
type: VibeEventType,
trackId?: string,
positionMs?: number,
durationMs?: number,
): Promise<DurableVibeSessionResponse | null> {
const sessionId = useVibeStore.getState().activeSessionId;
if (!sessionId) return null;
const input = {
eventId: newEventId(),
type,
trackId,
occurredAt: new Date().toISOString(),
positionMs,
durationMs,
};
return new Promise<DurableVibeSessionResponse>((resolve, reject) => {
eventOutbox.push({ sessionId, input, retried: false, settled: false, transientAttempts: 0, resolve, reject });
void flushEventOutbox();
});
}
function retryableEventError(error: unknown): boolean {
return !isSessionTerminalError(error);
}
/**
* A failure the request never survived to reach an opinion about: no response
* at all (offline, timeout, DNS), or a server that is momentarily unable rather
* than refusing. These say nothing about the session, so they must not be
* allowed to discard a valid plan.
*/
function isTransientEventError(error: unknown): boolean {
if (!axios.isAxiosError(error)) return false;
if (!error.response) return true;
return error.response.status === 429 || error.response.status >= 500;
}
/** Wait out a backoff, but come back early the moment the network returns. */
function waitBeforeRetry(attempt: number): Promise<void> {
const delay = TRANSIENT_BACKOFF_MS[Math.min(attempt, TRANSIENT_BACKOFF_MS.length - 1)];
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
window.removeEventListener('online', finish);
resolve();
};
const timer = setTimeout(finish, delay);
window.addEventListener('online', finish);
});
}
async function flushEventOutbox(): Promise<void> {
if (flushingOutbox) return;
flushingOutbox = true;
try {
while (eventOutbox.length > 0) {
const entry = eventOutbox[0];
try {
const response = await vibeService.event(entry.sessionId, entry.input);
eventOutbox.shift();
entry.settled = true;
entry.resolve(response);
} catch (error) {
// Retry once immediately using the exact same client event id. After
// that leave it at the head for a later retry, rather than discarding
// the idempotency key or allowing newer material events to overtake it.
if (!entry.retried && retryableEventError(error)) {
entry.retried = true;
continue;
}
// The network failed, not the session. Hold the entry unsettled and
// keep trying: rejecting here is what used to end the Vibe whenever a
// locked phone lost its connection for a moment.
if (isTransientEventError(error) && entry.transientAttempts < TRANSIENT_BACKOFF_MS.length) {
const attempt = entry.transientAttempts;
entry.transientAttempts += 1;
await waitBeforeRetry(attempt);
continue;
}
// A session that is gone/ended can never acknowledge this event. Do
// not let an irrecoverable old-session entry block a later session.
if (isSessionTerminalError(error)) eventOutbox.shift();
if (!entry.settled) {
entry.settled = true;
entry.reject(error);
}
return;
}
}
} finally {
flushingOutbox = false;
}
}
/**
* Send a non-navigation event. Material feedback reconciles the future before
* it resolves, so no old prefetch remains after Keep or an implicit update.
*/
export async function reportVibeEvent(
type: VibeEventType,
trackId?: string,
positionMs?: number,
durationMs?: number,
): Promise<void> {
return serializeMaterial(async () => {
const sessionId = useVibeStore.getState().activeSessionId;
if (!sessionId || !isCurrentVibeOwner(sessionId)) return;
try {
const response = await sendEvent(type, trackId, positionMs, durationMs);
// Even a duplicate material event can acknowledge a canonical
// replacement revision (replanned=false). Reconcile every valid
// revision so a response lost after its original replan cannot leave a
// stale locally-prefetched future behind.
if (response?.planVersion !== null && response?.planVersion !== undefined) {
await reconcilePreview(sessionId, response);
}
} catch (error) {
if (isSessionTerminalError(error)) deactivateBrokenSession();
throw error;
}
});
}
/** Advance only after the prior track's durable outcome has produced a new plan. */
export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
if (advanceInFlight) return advanceInFlight;
advanceInFlight = serializeMaterial(async () => {
const vibe = useVibeStore.getState();
const current = usePlaybackStore.getState().currentTrack;
if (!vibe.activeSessionId || !current || !isCurrentVibeOwner(vibe.activeSessionId)) return;
// The seed is not a plan item — step off it without touching the cursor.
if (seedPendingTrackId && current.id === seedPendingTrackId) {
seedPendingTrackId = null;
usePlaybackStore.getState().advance();
// A dislike still has to reach the director; a completed seed carries no
// information the session's seed id does not already hold.
if (reason !== 'completed') void sendEvent(reason, current.id).catch(() => undefined);
return;
}
try {
const feedback = await sendEvent(reason, current.id);
if (!feedback?.planVersion) {
usePlaybackStore.getState().pause();
return;
}
const served = await serveNextPlayableRetrying(vibe.activeSessionId, feedback.planVersion);
if (!served || served.response.sessionId !== vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) {
// Never play an uncommitted or unresolvable plan item. The user can
// retry from the page after the director publishes another revision.
replaceUnplayedQueue([]);
usePlaybackStore.getState().pause();
return;
}
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
useVibeStore.getState().setProfile(served.response.state);
useVibeStore.getState().setCurrentPlanItem(served.response.now);
replaceUnplayedQueue([served.now, ...served.preview]);
usePlaybackStore.getState().advance();
} catch (error) {
// A network failure that outlived every retry leaves the plan valid and
// the prefetched future worth keeping, so the listener can carry on from
// the page once they are back on a connection.
if (isTransientEventError(error)) {
usePlaybackStore.getState().pause();
throw error;
}
// Clearing the future is deliberate: carrying on with stale prefetches
// after a rejected feedback/replan would violate the plan boundary.
replaceUnplayedQueue([]);
if (isSessionTerminalError(error)) deactivateBrokenSession();
else usePlaybackStore.getState().pause();
throw error;
}
}).finally(() => { advanceInFlight = null; });
return advanceInFlight;
}
/**
* A stream can fail after its track metadata was successfully hydrated. This
* advances the exact durable cursor through the explicit unplayable protocol,
* rather than treating it as ordinary feedback and allowing a replan to hide
* the failure.
*/
export function advancePastUnplayableVibeTrack(trackId: string): Promise<void> {
if (advanceInFlight) return advanceInFlight;
// A session adopted from another device has no cursor until its first advance.
// Without one there is no durable item to step past, so report the failure as
// an ordinary skip rather than stalling on a track that will not play.
const adopted = useVibeStore.getState();
if (
adopted.activeSessionId && !adopted.currentPlanItem && seedPendingTrackId !== trackId
&& usePlaybackStore.getState().currentTrack?.id === trackId
) {
return advanceVibe('skipped');
}
advanceInFlight = serializeMaterial(async () => {
const vibe = useVibeStore.getState();
const playback = usePlaybackStore.getState();
const currentItem = vibe.currentPlanItem;
if (!vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) return;
// The seed has no durable cursor to advance — a seed that will not stream is
// simply stepped over, leaving the plan's first item to play next.
if (seedPendingTrackId === trackId) {
seedPendingTrackId = null;
playback.advance();
return;
}
if (!currentItem || currentItem.track_id !== trackId) return;
try {
const advanced = await advanceResponsePastUnplayable(vibe.activeSessionId, {
sessionId: vibe.activeSessionId,
planVersion: vibe.planVersion,
now: currentItem,
preview: [],
state: {},
replanned: false,
replanReason: null,
});
const served = await resolvePlayableResponse(vibe.activeSessionId, advanced);
if (!served || served.response.sessionId !== vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) {
replaceUnplayedQueue([]);
playback.pause();
return;
}
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
useVibeStore.getState().setProfile(served.response.state);
useVibeStore.getState().setCurrentPlanItem(served.response.now);
replaceUnplayedQueue([served.now, ...served.preview]);
playback.advance();
} catch (error) {
replaceUnplayedQueue([]);
if (isSessionTerminalError(error)) deactivateBrokenSession();
else playback.pause();
throw error;
}
}).finally(() => { advanceInFlight = null; });
return advanceInFlight;
}
function installVibeAdvanceHandler(): void {
usePlaybackStore.getState().setVibeAdvanceHandler((reason) => {
void advanceVibe(reason).catch(() => undefined);
});
}
/** Start, version-serve and hydrate the first durable Vibe track. */
export async function startVibeSession(seed: Track): Promise<StartedVibeSession> {
if (startInFlight) return startInFlight;
startInFlight = serializeMaterial<StartedVibeSession>(async () => {
const started = await vibeService.start(seed.id, localCalendarContext());
if (!started.planVersion) return { status: 'exhausted', tracks: [] };
const served = await serveNextPlayable(started.sessionId, started.planVersion);
if (!served) return { status: 'exhausted', tracks: [] };
const vibe = useVibeStore.getState();
// A newly started session has its own revision sequence. Drop the old
// local revision before admitting revision 1 from this new session.
vibe.reset();
vibe.setInitialBatchStatus('loading');
vibe.setActiveSession({ sessionId: started.sessionId, seedTrackId: seed.id });
vibe.setCenterTrack(seed);
vibe.setPlan(served.response.planVersion, served.preview);
vibe.setProfile(served.response.state);
vibe.setCurrentPlanItem(served.response.now);
vibe.setInitialBatchStatus('idle');
const playback = usePlaybackStore.getState();
const seedFirst = isPlayable(seed) && seed.id !== served.now.id;
seedPendingTrackId = seedFirst ? seed.id : null;
const queue = dedupeSongs(
seedFirst ? [seed, served.now, ...served.preview] : [served.now, ...served.preview]
);
playback.setVibeQueue(queue);
playback.playTrack(queue[0]);
installVibeAdvanceHandler();
return { status: 'complete', tracks: queue };
});
try {
return await startInFlight;
} finally {
startInFlight = null;
}
}
/**
* Take over a session that is already running, on the device that has just been
* given the audio.
*
* A Vibe has two halves: the durable plan on the server, and the controller here
* that reports outcomes and asks for the next track. Only one device may hold
* the controller, and the right one is whichever holds the audio otherwise the
* device that started the session keeps replanning for a player it cannot hear,
* or, as it did before this existed, nobody replans at all and the Vibe quietly
* becomes a fixed list of whatever tracks were synced.
*
* The queue and playing track are already in place from the playback snapshot;
* this restores the session around them.
*/
export async function adoptVibeSession(sessionId: string): Promise<boolean> {
return serializeMaterial(async () => {
const playback = usePlaybackStore.getState();
const alreadyDriving = useVibeStore.getState().activeSessionId === sessionId
&& playback.queueOwner === 'vibe'
&& playback.vibeAdvanceHandler !== null;
if (alreadyDriving) return true;
let response: DurableVibeSessionResponse;
try {
response = await vibeService.resume(sessionId);
} catch (error) {
// Ended, replaced or simply gone: there is nothing to drive, and the
// ordinary queue this device received is the honest thing to keep playing.
if (isSessionTerminalError(error)) return false;
throw error;
}
if (!response.planVersion) return false;
const preview = await hydratePreview(response.preview);
const vibe = useVibeStore.getState();
// A different session, or a stale local revision of this one: drop it before
// admitting the revision the server just reported.
if (vibe.activeSessionId !== sessionId) vibe.reset();
vibe.setActiveSession({ sessionId, seedTrackId: response.session?.seed_track_id ?? null });
vibe.setPlan(response.planVersion, preview);
vibe.setProfile(response.state);
// The cursor for the track already playing was committed in a revision that
// a later replan superseded, so it is not in this response and cannot be
// reconstructed. The first advance sets it; until then a stream that fails
// is reported as an ordinary skip.
vibe.setCurrentPlanItem(null);
// Ownership first: replaceUnplayedQueue and every session guard read it.
usePlaybackStore.getState().setVibeQueue(usePlaybackStore.getState().queue);
installVibeAdvanceHandler();
replaceUnplayedQueue(preview);
return true;
});
}
/**
* Give up driving the session without ending it, because the audio has moved to
* another device. The session stays on record here so the local Vibe view still
* has something to show, and taking the audio back adopts it again.
*/
export function releaseVibeDriving(): void {
usePlaybackStore.getState().setVibeAdvanceHandler(null);
}
export async function endVibeSession(): Promise<void> {
return serializeMaterial(async () => {
const sessionId = useVibeStore.getState().activeSessionId;
try {
if (sessionId) await vibeService.end(sessionId);
} finally {
seedPendingTrackId = null;
const playback = usePlaybackStore.getState();
playback.setVibeAdvanceHandler(null);
useVibeStore.getState().reset();
playback.pause();
playback.setQueue([]);
playback.setCurrentTrack(null);
}
});
}
+98 -4
View File
@@ -1,7 +1,18 @@
import { create } from 'zustand';
import type { Track } from '../types';
import {
clampCrossfadeMs,
readStoredCrossfadeMs,
readStoredLastTrack,
readStoredPrefetchNext,
storeCrossfadeMs,
storeLastTrack,
storePrefetchNext,
} from '../lib/playbackPrefs';
export type RepeatMode = 'none' | 'all' | 'one';
export type VibeAdvanceReason = 'skipped' | 'completed' | 'disliked';
export type PlaybackOwner = 'ordinary' | 'vibe';
/**
* How many already-played tracks to keep behind the cursor. Bounds queue growth
@@ -20,19 +31,42 @@ interface PlaybackState {
volume: number;
shuffle: boolean;
repeat: RepeatMode;
/** Warm the next track's stream into the idle audio element before this one ends. */
prefetchNext: boolean;
/** Overlap between tracks, in milliseconds. 0 disables the fade. */
crossfadeMs: number;
/** Ids already played this shuffle "lap" (repeat-all), to avoid bouncing between the same few tracks. */
shufflePlayed: Set<string>;
/** Installed only while a durable Vibe session owns the queue. */
vibeAdvanceHandler: ((reason: VibeAdvanceReason) => void) | null;
/** Vibe must opt in explicitly; ordinary browsing always owns itself. */
queueOwner: PlaybackOwner;
/**
* True while another device holds the audio and this one is only showing what
* it plays. The engine loads no stream in that state, so a phone watching the
* desktop stops pulling megabytes of audio it will never play.
*/
audioElsewhere: boolean;
setQueue: (queue: Track[]) => void;
/** Vibe-only queue replacement. Do not use for library browsing. */
setVibeQueue: (queue: Track[]) => void;
playTrack: (track: Track) => void;
play: () => void;
pause: () => void;
next: () => void;
nextWithReason: (reason: VibeAdvanceReason) => void;
/** Bypass the Vibe controller after it has prepared the next committed track. */
advance: () => void;
setVibeAdvanceHandler: (handler: ((reason: VibeAdvanceReason) => void) | null) => void;
prev: () => void;
setPosition: (position: number) => void;
setDuration: (duration: number) => void;
setVolume: (volume: number) => void;
setPrefetchNext: (prefetchNext: boolean) => void;
setCrossfadeMs: (crossfadeMs: number) => void;
setCurrentTrack: (track: Track | null) => void;
setAudioElsewhere: (audioElsewhere: boolean) => void;
toggleShuffle: () => void;
cycleRepeat: () => void;
}
@@ -60,17 +94,26 @@ function advanceTo(queue: Track[], index: number) {
};
}
// A fresh tab opens on the last track it played, paused at zero — an empty
// player bar told the listener nothing about where they were.
const restoredTrack = readStoredLastTrack<Track>();
export const usePlaybackStore = create<PlaybackState>((set, get) => ({
currentTrack: null,
queue: [],
currentIndex: -1,
currentTrack: restoredTrack,
queue: restoredTrack ? [restoredTrack] : [],
currentIndex: restoredTrack ? 0 : -1,
isPlaying: false,
position: 0,
duration: 0,
duration: restoredTrack?.duration ?? 0,
volume: 1,
shuffle: false,
repeat: 'none',
prefetchNext: readStoredPrefetchNext(),
crossfadeMs: readStoredCrossfadeMs(),
shufflePlayed: new Set<string>(),
vibeAdvanceHandler: null,
queueOwner: 'ordinary',
audioElsewhere: false,
setQueue: (queue) =>
set((state) => ({
@@ -78,6 +121,18 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
// Keep the cursor pointing at whatever is playing, if it is still queued.
currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1,
shufflePlayed: new Set(),
// Every ordinary queue operation is an explicit ownership handoff. This
// prevents a stale Vibe session from intercepting browser/UI next.
queueOwner: 'ordinary',
vibeAdvanceHandler: null,
})),
setVibeQueue: (queue) =>
set((state) => ({
queue,
currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1,
shufflePlayed: new Set(),
queueOwner: 'vibe',
})),
playTrack: (track) =>
@@ -94,6 +149,19 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
pause: () => set({ isPlaying: false }),
next: () => {
get().nextWithReason('skipped');
},
nextWithReason: (reason) => {
const { vibeAdvanceHandler: handler, queueOwner } = get();
if (queueOwner === 'vibe' && handler) {
handler(reason);
return;
}
get().advance();
},
advance: () => {
const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get();
if (queue.length === 0) {
set({ isPlaying: false, position: 0 });
@@ -169,6 +237,11 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
}
},
setVibeAdvanceHandler: (vibeAdvanceHandler) => set((state) => ({
vibeAdvanceHandler,
queueOwner: vibeAdvanceHandler ? 'vibe' : state.queueOwner,
})),
prev: () => {
const { queue, currentTrack, currentIndex } = get();
if (queue.length === 0) return;
@@ -193,12 +266,23 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
setPosition: (position) => set({ position }),
setDuration: (duration) => set({ duration }),
setVolume: (volume) => set({ volume }),
setPrefetchNext: (prefetchNext) => {
storePrefetchNext(prefetchNext);
set({ prefetchNext });
},
setCrossfadeMs: (value) => {
const crossfadeMs = clampCrossfadeMs(value);
storeCrossfadeMs(crossfadeMs);
set({ crossfadeMs });
},
setCurrentTrack: (currentTrack) =>
set((state) => ({
currentTrack,
currentIndex: currentTrack ? state.queue.findIndex((t) => t.id === currentTrack.id) : -1,
})),
setAudioElsewhere: (audioElsewhere) => set({ audioElsewhere }),
toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })),
cycleRepeat: () =>
set((state) => {
@@ -207,3 +291,13 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
return { repeat: next };
}),
}));
// One subscription instead of a write in playTrack, advance, prev and the
// shuffle pick — every path that changes the track goes through here.
let lastPersistedTrackId = restoredTrack?.id ?? null;
usePlaybackStore.subscribe((state) => {
const id = state.currentTrack?.id ?? null;
if (id === lastPersistedTrackId) return;
lastPersistedTrackId = id;
storeLastTrack(state.currentTrack);
});
+32 -24
View File
@@ -1,53 +1,61 @@
import { create } from 'zustand';
import type { Track, VibeSession } from '../types';
import type { VibePlanItem } from '../services/vibeService';
import type { VibeProfile } from '../components/VibeAura';
// V2 recommendation session state. The backend stores the plan in Redis
// (keyed by sessionId) and serves tracks one at a time via GET /v2/vibe/next.
// We keep a lookahead buffer of upcoming Track[] to feed playback.
// The durable plan is authoritative. `buffer` is only its currently
// uncommitted, hydrated preview; it may be replaced at any feedback boundary.
interface VibeState {
activeSessionId: string | null;
seedTrackId: string | null;
planVersion: number | null;
/** Durable cursor for the track currently in Vibe playback. */
currentPlanItem: VibePlanItem | null;
profile: VibeProfile;
centerTrack: Track | null;
buffer: Track[]; // lookahead buffer of upcoming recommended tracks
buffer: Track[];
initialBatchStatus: 'idle' | 'loading' | 'exhausted' | 'failed';
setActiveSession: (session: VibeSession | null) => void;
setSeedTrackId: (seedTrackId: string | null) => void;
setCenterTrack: (track: Track | null) => void;
setBuffer: (buffer: Track[]) => void;
appendBuffer: (tracks: Track[]) => void;
shiftBuffer: () => Track | undefined;
/** Returns false when a response belongs to an older plan revision. */
setPlan: (planVersion: number | null, preview: Track[]) => boolean;
setCurrentPlanItem: (item: VibePlanItem | null) => void;
setProfile: (profile: VibeProfile) => void;
setInitialBatchStatus: (status: VibeState['initialBatchStatus']) => void;
reset: () => void;
}
const initialState = {
activeSessionId: null as string | null,
seedTrackId: null as string | null,
planVersion: null as number | null,
currentPlanItem: null as VibePlanItem | null,
profile: {} as VibeProfile,
centerTrack: null as Track | null,
buffer: [] as Track[],
initialBatchStatus: 'idle' as const,
};
export const useVibeStore = create<VibeState>((set, get) => ({
...initialState,
setActiveSession: (session) =>
set(
session
? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId }
: { activeSessionId: null, seedTrackId: null }
),
setActiveSession: (session) => set(
session
? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId }
: { activeSessionId: null, seedTrackId: null },
),
setSeedTrackId: (seedTrackId) => set({ seedTrackId }),
setCenterTrack: (centerTrack) => set({ centerTrack }),
setBuffer: (buffer) => set({ buffer }),
appendBuffer: (tracks) => set((state) => ({ buffer: [...state.buffer, ...tracks] })),
shiftBuffer: () => {
const { buffer } = get();
if (buffer.length === 0) return undefined;
const [head, ...rest] = buffer;
set({ buffer: rest });
return head;
setPlan: (planVersion, buffer) => {
const current = get().planVersion;
if (planVersion === null || (current !== null && planVersion < current)) return false;
set({ planVersion, buffer });
return true;
},
setCurrentPlanItem: (currentPlanItem) => set({ currentPlanItem }),
setProfile: (profile) => set({ profile }),
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
reset: () => set({ ...initialState }),
}));
+5
View File
@@ -0,0 +1,5 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => cleanup());

Some files were not shown because too many files have changed in this diff Show More