feat(vibe): reconcile mutable session previews in playback

This commit is contained in:
kami
2026-08-01 23:47:02 +04:00
parent 51ef7c84db
commit 57df1cfe9f
20 changed files with 1311 additions and 312 deletions
+5
View File
@@ -39,6 +39,11 @@ A high-performance, distributed music orchestration and recommendation platform.
- Docker & Docker Compose
### Running Locally
Set `MUZICK_VIBE_USER_ID` in `.env` to the UUID of the local Muzick user before
using Vibe. Durable Vibe session routes intentionally reject client-supplied
identities, so this is the trusted single-user binding for a self-hosted stack.
```bash
docker-compose up -d
```
@@ -22,6 +22,7 @@ async function appWithCoordinator(identityResolver: VibeIdentityResolver = () =>
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 });
@@ -133,4 +134,31 @@ describe('durable Vibe session routes', () => {
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();
const result = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`,
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();
});
});
@@ -156,8 +156,33 @@ export default async function vibeSessionsRoutes(
&& (!Number.isInteger(body.expectedPlanVersion) || (body.expectedPlanVersion as number) < 1)) {
return reply.code(400).send({ error: 'expectedPlanVersion must be a positive integer' });
}
const unplayable = body.unplayable;
if (unplayable !== undefined && !isObject(unplayable)) {
return reply.code(400).send({ error: 'unplayable must be an object' });
}
if (isObject(unplayable)) {
if (body.expectedPlanVersion === undefined) {
return reply.code(400).send({ error: 'expectedPlanVersion is required when advancing an unplayable item' });
}
if (!validUuid(unplayable.eventId)
|| !validUuid(unplayable.planVersionId)
|| !validUuid(unplayable.trackId)
|| !Number.isInteger(unplayable.ordinal)
|| (unplayable.ordinal as number) < 0) {
return reply.code(400).send({ error: 'unplayable requires UUID eventId, planVersionId, trackId and a non-negative integer ordinal' });
}
}
try {
const expectedPlanVersion = body.expectedPlanVersion as number | undefined;
if (isObject(unplayable)) {
return reply.send(await coordinator.advancePastUnplayable(userId, sessionId, {
expectedPlanVersion: expectedPlanVersion as number,
eventId: unplayable.eventId as string,
planVersionId: unplayable.planVersionId as string,
ordinal: unplayable.ordinal as number,
trackId: unplayable.trackId as string,
}));
}
return reply.send(expectedPlanVersion === undefined
? await coordinator.serveNext(userId, sessionId)
: await coordinator.serveNext(userId, sessionId, expectedPlanVersion));
+89
View File
@@ -361,6 +361,95 @@ describe('DbService v2 methods', () => {
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: [{
+147
View File
@@ -2073,6 +2073,153 @@ export class DbService {
});
}
/**
* Advance an already-served item which the player could not resolve (for
* example, its file was hidden after the revision was published). Unlike a
* normal version-aware /next retry, this has a distinct client event id and
* therefore intentionally moves beyond the item previously served for that
* revision. The event records both the rejected item and the replacement so
* a lost response can be retried without consuming another plan item.
*/
async advancePastUnplayableVibePlanItem(
sessionId: string,
userId: string,
input: {
expectedPlanVersion: number;
planVersionId: string;
ordinal: number;
trackId: string;
eventId: string;
},
): Promise<{ item: VibePlanItem | null; stale: boolean }> {
return this.withTransaction(async (client) => {
const session = await client.query(
`SELECT status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
[sessionId, userId],
);
const sessionRow = session.rows[0] as Pick<VibeSession, 'status'> | undefined;
if (!sessionRow) throw new Error('Vibe session was not found or is not owned by this user');
const latest = await client.query(
`SELECT id, version FROM vibe_plan_versions WHERE session_id = $1 ORDER BY version DESC LIMIT 1 FOR UPDATE`,
[sessionId],
);
const plan = latest.rows[0] as Pick<VibePlan, 'id' | 'version'> | undefined;
if (!plan) return { item: null, stale: false };
if (plan.version !== input.expectedPlanVersion || plan.id !== input.planVersionId) {
return { item: null, stale: true };
}
// Idempotency is scoped to this explicit advancement operation, rather
// than overloading the version-aware /next retry which must keep
// returning the originally served item.
const prior = await client.query(
`SELECT type, payload
FROM vibe_events
WHERE session_id = $1 AND client_event_id = $2::uuid`,
[sessionId, input.eventId],
);
const priorEvent = prior.rows[0] as Pick<VibeEvent, 'type' | 'payload'> | undefined;
if (priorEvent && priorEvent.type !== 'playback_error') {
throw new Error('Vibe client event id was already used for a different event');
}
const priorPayload = priorEvent?.payload as Record<string, unknown> | undefined;
if (priorPayload) {
if (priorPayload.planVersionId !== input.planVersionId
|| priorPayload.ordinal !== input.ordinal
|| priorPayload.trackId !== input.trackId) {
throw new Error('Vibe playback-error event does not match the served plan item');
}
const advancedTo = priorPayload.advancedTo as { planVersionId?: unknown; ordinal?: unknown } | null | undefined;
if (!advancedTo || typeof advancedTo.planVersionId !== 'string' || !Number.isInteger(advancedTo.ordinal)) {
return { item: null, stale: false };
}
const replacement = await client.query(
`SELECT * FROM vibe_plan_items WHERE plan_version_id = $1 AND ordinal = $2`,
[advancedTo.planVersionId, advancedTo.ordinal],
);
return { item: (replacement.rows[0] as VibePlanItem | undefined) ?? null, stale: false };
}
if (sessionRow.status !== 'active') {
throw new Error(`Cannot record a new event for ${sessionRow.status} Vibe session`);
}
// A client may advance only the cursor it was just served. Checking for
// any historical serve event would let an old version-aware /next
// response consume whichever future item happens to be uncommitted.
const current = await client.query(
`SELECT i.track_id, i.ordinal
FROM vibe_events e
JOIN vibe_plan_items i
ON i.plan_version_id = $3::uuid
AND i.ordinal = (e.payload->>'ordinal')::integer
AND i.track_id = e.track_id
WHERE e.session_id = $1
AND e.type = 'track_served'
AND e.payload->>'planVersion' = $2::text
AND e.payload->>'planVersionId' = $3
ORDER BY i.ordinal DESC
LIMIT 1`,
[sessionId, input.expectedPlanVersion, input.planVersionId],
);
const currentCursor = current.rows[0] as Pick<VibePlanItem, 'track_id' | 'ordinal'> | undefined;
if (currentCursor?.track_id !== input.trackId || currentCursor.ordinal !== input.ordinal) {
throw new Error('Vibe plan item is not the current served cursor for this session revision');
}
const payload = {
kind: 'unplayable_plan_item',
planVersion: input.expectedPlanVersion,
planVersionId: input.planVersionId,
ordinal: input.ordinal,
trackId: input.trackId,
};
const playbackError = await client.query(
`INSERT INTO vibe_events (client_event_id, session_id, user_id, track_id, type, occurred_at, payload)
VALUES ($1::uuid, $2, $3, $4::uuid, 'playback_error', NOW(), $5::jsonb)
RETURNING id`,
[input.eventId, sessionId, userId, input.trackId, JSON.stringify(payload)],
);
const eventId = playbackError.rows[0]?.id as string | undefined;
if (!eventId) throw new Error('Vibe playback-error event could not be recorded');
const item = await client.query(
`WITH next_item AS (
SELECT i.plan_version_id, i.ordinal FROM vibe_plan_items i
WHERE i.plan_version_id = $1 AND NOT i.committed ORDER BY i.ordinal ASC LIMIT 1 FOR UPDATE
)
UPDATE vibe_plan_items i SET committed = true
FROM next_item n WHERE i.plan_version_id = n.plan_version_id AND i.ordinal = n.ordinal
RETURNING i.*`,
[plan.id],
);
const replacement = item.rows[0] as VibePlanItem | undefined;
if (replacement) {
await client.query(
`INSERT INTO vibe_events (session_id, user_id, track_id, type, occurred_at, payload)
VALUES ($1, $2, $3, 'track_served', NOW(), $4::jsonb)`,
[sessionId, userId, replacement.track_id, JSON.stringify({
planVersion: plan.version,
planVersionId: replacement.plan_version_id,
ordinal: replacement.ordinal,
})],
);
}
await client.query(
`UPDATE vibe_events SET payload = $2::jsonb WHERE id = $1`,
[eventId, JSON.stringify({
...payload,
advancedTo: replacement
? { planVersionId: replacement.plan_version_id, ordinal: replacement.ordinal }
: null,
})],
);
await client.query(`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, [sessionId]);
return { item: replacement ?? null, stale: false };
});
}
/** Read a specific plan revision, or the latest revision for a session. */
async getVibePlan(sessionId: string, userId: string, version?: number): Promise<VibePlan | null> {
const res = await this.pgClient.query(
@@ -47,6 +47,7 @@ function setup() {
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),
} as unknown as DbService;
@@ -179,6 +180,25 @@ describe('VibeSessionCoordinator', () => {
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);
@@ -36,6 +36,19 @@ export interface AppendVibeEventInput {
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;
}
export class VibeSessionNotFoundError extends Error {}
export class VibeSessionLifecycleError extends Error {}
export class VibePlanNotFoundError extends Error {}
@@ -142,6 +155,21 @@ export class VibeSessionCoordinator {
}
}
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,
+4
View File
@@ -32,6 +32,10 @@ services:
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY}
MUZICK_API_KEY: ${MUZICK_API_KEY}
MUZICK_ADMIN_KEY: ${MUZICK_ADMIN_KEY}
# Durable Vibe sessions are intentionally bound to this configured,
# server-trusted owner instead of accepting a client-supplied user id.
# Set it to the UUID of the local Muzick user in .env.
MUZICK_VIBE_USER_ID: ${MUZICK_VIBE_USER_ID}
MUSIC_DIR: /music
volumes:
# READ-ONLY, deliberately. Nothing in the API request path may write to
@@ -0,0 +1,45 @@
import { 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(() => {
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(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,
});
});
afterEach(() => vi.restoreAllMocks());
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');
});
});
+35 -27
View File
@@ -2,17 +2,9 @@ import { 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 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.
@@ -48,6 +40,8 @@ export const AudioEngine = () => {
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);
// --- DOM -> store: media events -----------------------------------------
useEffect(() => {
@@ -66,6 +60,13 @@ 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);
}
};
const onLoadedMetadata = () => {
if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
@@ -83,7 +84,20 @@ export const AudioEngine = () => {
// feedback, on the resulting track-change, so completion is recorded
// exactly once per track.
endedNaturallyRef.current = true;
store().next();
store().nextWithReason('completed');
};
const onError = () => {
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);
@@ -91,6 +105,7 @@ export const AudioEngine = () => {
audio.addEventListener('play', onPlay);
audio.addEventListener('pause', onPause);
audio.addEventListener('ended', onEnded);
audio.addEventListener('error', onError);
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
@@ -98,6 +113,7 @@ export const AudioEngine = () => {
audio.removeEventListener('play', onPlay);
audio.removeEventListener('pause', onPause);
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('error', onError);
};
}, []);
@@ -109,28 +125,19 @@ export const AudioEngine = () => {
const applyTrack = (id: string | null) => {
if (id === loadedIdRef.current) return;
// 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', useVibeStore.getState().activeSessionId ?? undefined).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);
}
endedNaturallyRef.current = false;
crossedThresholdRef.current = false;
lastProgressSecondRef.current = -1;
loadedIdRef.current = id;
if (!id) {
@@ -141,6 +148,7 @@ export const AudioEngine = () => {
audio.src = trackService.getStreamUrl(id);
audio.load();
if (inVibePlayback) void reportVibeEvent('playback_started', id).catch(() => undefined);
if (usePlaybackStore.getState().isPlaying) {
void audio.play().catch(() => {});
}
+10 -3
View File
@@ -26,9 +26,11 @@ interface TrackRowProps {
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, onSelect }: 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();
@@ -36,6 +38,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
const compact = variant === 'compact';
const handlePlay = () => {
if (!playable) return;
if (onSelect) {
onSelect(track);
return;
@@ -47,7 +50,9 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
playTrack(track);
};
const playLabel = isCurrent && isPlaying
const playLabel = !playable
? `${track.title || 'Track'} is queued by Vibe`
: isCurrent && isPlaying
? `Pause ${track.title || 'track'}`
: `Play ${track.title || 'track'}`;
@@ -81,8 +86,9 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
<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 ${
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 disabled:cursor-default disabled:opacity-70 ${
compact ? 'h-9 w-9' : 'h-10 w-10'
}`}>
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
@@ -98,6 +104,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
<button
type="button"
onClick={handlePlay}
disabled={!playable}
className={`block max-w-full truncate rounded text-left font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}
aria-label={playLabel}
>
@@ -0,0 +1,41 @@
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 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,
});
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();
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />
</QueryClientProvider>
);
const queued = screen.getAllByRole('button', { name: 'upcoming is queued by Vibe' });
expect(queued).toHaveLength(2);
expect(queued[0]).toBeDisabled();
expect(queued[1]).toBeDisabled();
await user.click(queued[0]);
expect(usePlaybackStore.getState()).toMatchObject({
queueOwner: 'vibe', currentTrack: track('current'),
});
});
});
+1
View File
@@ -44,6 +44,7 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
index={0}
showActions={false}
variant="compact"
playable={false}
/>
))}
</div>
+13 -109
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, 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 { startVibeSession } from '../services/vibeSession';
import { advanceVibe, endVibeSession, reportVibeEvent, startVibeSession, vibeErrorMessage } from '../services/vibeSession';
import { trackService } from '../services/trackService';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
@@ -10,17 +9,10 @@ import { TrackRow } from '../components/TrackRow';
import { PageContainer } from '../components/PageContainer';
import type { Track } from '../types';
import { VibeTimeline } from '../components/VibeTimeline';
import { suppressAutoFeedback } from '../components/AudioEngine';
import { toast } from '../store/useToastStore';
const PREFETCH_THRESHOLD = 3;
const PREFETCH_BATCH_SIZE = 3;
const SEED_LIST_SIZE = 50;
function bestEffort(p: Promise<unknown>): void {
void p.catch(() => undefined);
}
function sampleTracks(tracks: Track[], count: number): Track[] {
const sampled = [...tracks];
for (let index = sampled.length - 1; index > 0; index--) {
@@ -31,23 +23,17 @@ function sampleTracks(tracks: Track[], count: number): Track[] {
}
export default function Vibe() {
const { currentTrack, queue, setQueue, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
const { currentTrack } = usePlaybackStore();
const {
activeSessionId,
buffer,
initialBatchStatus,
setBuffer,
appendBuffer,
reset,
planVersion,
} = useVibeStore();
const [starting, setStarting] = useState(false);
const [prefetching, setPrefetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [empty, setEmpty] = useState(false);
const [refillStatus, setRefillStatus] = useState<'idle' | 'exhausted' | 'failed'>('idle');
const [refillAttempt, setRefillAttempt] = useState(0);
const prefetchingRef = useRef(false);
const startingRef = useRef(false);
const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery<Track[]>({
@@ -66,17 +52,14 @@ export default function Vibe() {
setStarting(true);
setError(null);
setEmpty(false);
setRefillStatus('idle');
try {
const result = await startVibeSession(seed);
if (result.tracks.length === 0) {
setEmpty(true);
if (result.status === 'failed') {
setError('Could not load recommendations for this vibe. Please try another seed.');
}
}
} catch {
setError('Could not start a vibe session. Please try again.');
} catch (startError) {
setError(vibeErrorMessage(startError));
} finally {
startingRef.current = false;
setStarting(false);
@@ -95,91 +78,25 @@ export default function Vibe() {
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 (initialBatchStatus === 'loading') return;
if (refillStatus !== 'idle') return;
if (remaining > PREFETCH_THRESHOLD) return;
prefetchingRef.current = true;
setPrefetching(true);
fetchNextBatch(PREFETCH_BATCH_SIZE, activeSessionId)
.then((result) => {
if (result.tracks.length > 0) {
const current = usePlaybackStore.getState().queue;
const currentIds = new Set(current.map((t) => t.id));
const fresh = result.tracks.filter((t) => !currentIds.has(t.id));
if (fresh.length > 0) {
appendBuffer(fresh);
setQueue([...current, ...fresh]);
}
if (result.status === 'exhausted' || fresh.length === 0) {
setRefillStatus('exhausted');
}
} else if (result.status === 'exhausted') {
setRefillStatus('exhausted');
} else {
setRefillStatus('failed');
}
})
.finally(() => {
prefetchingRef.current = false;
setPrefetching(false);
});
}, [activeSessionId, initialBatchStatus, remaining, appendBuffer, refillAttempt, refillStatus, 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', activeSessionId ?? undefined));
void reportVibeEvent('kept', currentTrack.id).catch((feedbackError) => setError(vibeErrorMessage(feedbackError)));
toast.success(`Kept "${currentTrack.title}"`);
}
}, [currentTrack]);
const handleDislike = useCallback(() => {
if (currentTrack) {
bestEffort(vibeService.feedback(currentTrack.id, 'disliked', activeSessionId ?? undefined));
// 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);
setRefillStatus('idle');
}, [reset, pause, setQueue, setCurrentTrack]);
const retryRefill = useCallback(() => {
setRefillStatus('idle');
setRefillAttempt((attempt) => attempt + 1);
}, []);
const upcoming = currentTrack
? (() => {
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
return idx >= 0 ? buffer.slice(idx + 1) : buffer;
})()
: buffer;
const upcoming = buffer;
// ---- Start screen (no active session) ----
if (!activeSessionId) {
@@ -272,7 +189,7 @@ export default function Vibe() {
<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" />}
{initialBatchStatus === 'loading' && <Loader2 size={16} className="animate-spin text-muted" />}
</div>
<button
onClick={handleEnd}
@@ -297,20 +214,7 @@ export default function Vibe() {
</div>
)}
{refillStatus === 'exhausted' && !empty && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
This Vibe has no new recommendations to add. Playback will stop when the current queue ends; start a new Vibe to continue.
</div>
)}
{refillStatus === 'failed' && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
<span>Couldn&apos;t refresh the Vibe recommendations. Playback will stop when the current queue ends.</span>
<button onClick={retryRefill} className="flex-none rounded border border-red-400/50 px-2 py-1 text-xs hover:bg-red-500/10">
Try again
</button>
</div>
)}
{planVersion && <p className="text-xs text-muted/70">Plan revision {planVersion}; upcoming tracks may change as you listen.</p>}
{currentTrack && (
<div className="flex items-center gap-3">
+32 -23
View File
@@ -1,32 +1,41 @@
import { AxiosError } from 'axios';
import { describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { fetchNextBatch, vibeService } from './vibeService';
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 { 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/next', { expectedPlanVersion: 3 });
});
function responseError(status: number, code?: string) {
return new AxiosError('request failed', undefined, undefined, undefined, {
data: code ? { code } : {}, status, statusText: 'error', headers: {}, config: {} as never,
});
}
it('uses an explicit idempotency key to advance a served but unplayable item', async () => {
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 3, now: null, preview: [] } });
describe('fetchNextBatch', () => {
it('uses the supplied session id and treats VIBE_PLAN_EXHAUSTED as terminal', async () => {
const next = vi.spyOn(vibeService, 'next')
.mockResolvedValueOnce({ track: track('one'), explanation: null, planRemaining: 0 })
.mockRejectedValueOnce(responseError(409, 'VIBE_PLAN_EXHAUSTED'));
await expect(fetchNextBatch(3, 'session-a')).resolves.toEqual({ tracks: [track('one')], status: 'exhausted' });
expect(next).toHaveBeenCalledWith('session-a');
await vibeService.advancePastUnplayable('session', 3, {
eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track',
});
it('does not disguise a missing or replaced session as normal exhaustion', async () => {
vi.spyOn(vibeService, 'next').mockRejectedValue(responseError(404));
await expect(fetchNextBatch(1, 'expired-session')).resolves.toEqual({ tracks: [], status: 'failed' });
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/next', {
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,
}));
});
});
+75 -62
View File
@@ -1,88 +1,101 @@
import api from './api';
import axios from 'axios';
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[];
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 VibeBatchStatus = 'complete' | 'exhausted' | 'failed';
export interface VibeBatchResult {
tracks: Track[];
status: VibeBatchStatus;
export interface VibeEventResponse extends DurableVibeSessionResponse {
event: { id: string; client_event_id: string | null; type: string };
idempotent: boolean;
}
export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked';
/** 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): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId });
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(sessionId: string): Promise<VibeNextResponse> {
const res = await api.get<VibeNextResponse>('/v2/vibe/next', { params: { sessionId } });
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;
},
// 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, sessionId?: string): Promise<{ status: string; planRemaining: number }> {
const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action, sessionId });
async next(sessionId: string, expectedPlanVersion: number): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/next`, {
expectedPlanVersion,
});
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 advancePastUnplayable(
sessionId: string,
expectedPlanVersion: number,
unplayable: VibeUnplayableItemInput,
): Promise<DurableVibeSessionResponse> {
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/next`, {
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
// 409/VIBE_PLAN_EXHAUSTED is a normal terminal condition. A missing/replaced
// session is intentionally reported as a failure so callers can preserve the
// current playback state rather than pretending the plan completed cleanly.
export async function fetchNextBatch(count: number, sessionId: string): Promise<VibeBatchResult> {
const tracks: Track[] = [];
for (let i = 0; i < count; i++) {
try {
const { track } = await vibeService.next(sessionId);
tracks.push(track);
} catch (error) {
return {
tracks,
status: axios.isAxiosError(error) && error.response?.status === 409 &&
(error.response.data as { code?: string } | undefined)?.code === 'VIBE_PLAN_EXHAUSTED'
? 'exhausted' : 'failed',
};
}
}
return { tracks, status: 'complete' };
}
+229 -31
View File
@@ -1,21 +1,16 @@
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 { next, start } = vi.hoisted(() => ({ next: vi.fn(), start: vi.fn() }));
vi.mock('./vibeService', () => ({
vibeService: { start, next },
fetchNextBatch: async (count: number, sessionId: string) => {
const tracks: Track[] = [];
for (let index = 0; index < count; index++) {
try { tracks.push((await next(sessionId)).track); } catch { return { tracks, status: 'failed' as const }; }
}
return { tracks, status: 'complete' as const };
},
const { start, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
start: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
}));
vi.mock('./vibeService', () => ({ vibeService: { start, next, advancePastUnplayable, event, end } }));
vi.mock('./trackService', () => ({ trackService: { getTrack } }));
import { startVibeSession } from './vibeSession';
import { advancePastUnplayableVibeTrack, advanceVibe, endVibeSession, reportVibeEvent, startVibeSession } from './vibeSession';
const track = (id: string): Track => ({
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
@@ -23,34 +18,237 @@ const track = (id: string): Track => ({
play_count: 0, skip_count: 0, dislike_count: 0,
});
describe('startVibeSession', () => {
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 = 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 });
usePlaybackStore.setState({
currentTrack: null, queue: [], currentIndex: -1, isPlaying: false, vibeAdvanceHandler: null, queueOwner: 'ordinary',
});
getTrack.mockImplementation((id: string) => Promise.resolve(track(id)));
});
it('does not replace a working Vibe when the new plan cannot hydrate', async () => {
const old = track('old');
useVibeStore.getState().setActiveSession({ sessionId: 'old-session', seedTrackId: old.id });
usePlaybackStore.getState().setQueue([old]);
usePlaybackStore.getState().playTrack(old);
start.mockResolvedValue({ sessionId: 'new-session', plan: [] });
next.mockRejectedValue(new Error('missing session'));
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('seed'))).resolves.toMatchObject({ tracks: [], status: 'failed' });
expect(useVibeStore.getState().activeSessionId).toBe('old-session');
expect(usePlaybackStore.getState().currentTrack?.id).toBe('old');
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] });
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('serializes rapid starts and hydrates only one session', async () => {
const recommended = track('recommended');
start.mockResolvedValue({ sessionId: 'session-a', plan: [] });
next.mockResolvedValue({ track: recommended });
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('seed'));
await Promise.all([startVibeSession(track('seed-a')), startVibeSession(track('seed-b'))]);
expect(start).toHaveBeenCalledTimes(1);
expect(next).toHaveBeenCalledWith('session-a');
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
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('seed'));
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('seed'));
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('seed'));
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('seed'));
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('seed'));
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('seed'));
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('seed'))).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('seed'))).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('seed'));
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('seed'));
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 });
});
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('seed'));
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,
});
});
});
+414 -29
View File
@@ -1,25 +1,417 @@
import axios from 'axios';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { usePlaybackStore, type VibeAdvanceReason } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { fetchNextBatch, vibeService, type VibeBatchStatus } from './vibeService';
export const INITIAL_VIBE_BATCH_SIZE = 5;
import {
vibeService,
type DurableVibeSessionResponse,
type VibeEventType,
type VibePlanItem,
} from './vibeService';
import { trackService } from './trackService';
export interface StartedVibeSession {
status: VibeBatchStatus;
status: 'complete' | 'exhausted' | 'failed';
tracks: Track[];
}
let startInFlight: Promise<StartedVibeSession> | null = null;
let advanceInFlight: Promise<void> | null = null;
let materialTail: Promise<void> = Promise.resolve();
interface PendingEvent {
sessionId: string;
input: Parameters<typeof vibeService.event>[1];
retried: boolean;
settled: boolean;
resolve: (response: DurableVibeSessionResponse) => void;
reject: (error: unknown) => void;
}
// 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 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] : [];
});
}
/** 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] : [];
const seen = new Set(history.map((track) => track.id));
const future = preview.filter((track) => !seen.has(track.id));
playback.setVibeQueue([...history, ...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 [];
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));
}
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 {
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 401: return 'Vibe needs a trusted local user identity. Set MUZICK_VIBE_USER_ID and try again.';
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, resolve, reject });
void flushEventOutbox();
});
}
function retryableEventError(error: unknown): boolean {
return !isSessionTerminalError(error);
}
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;
}
// 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;
}
}
/**
* Start a V2 plan and immediately hand its first recommendations to playback.
* Keeping this in one place prevents entry points from accidentally replacing a
* generated Vibe queue with a normal browse queue.
* 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;
try {
const feedback = await sendEvent(reason, current.id);
if (!feedback?.planVersion) {
usePlaybackStore.getState().pause();
return;
}
const served = await serveNextPlayable(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().setCurrentPlanItem(served.response.now);
replaceUnplayedQueue([served.now, ...served.preview]);
usePlaybackStore.getState().advance();
} catch (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;
advanceInFlight = serializeMaterial(async () => {
const vibe = useVibeStore.getState();
const playback = usePlaybackStore.getState();
const currentItem = vibe.currentPlanItem;
if (!vibe.activeSessionId || !currentItem || currentItem.track_id !== trackId || !isCurrentVibeOwner(vibe.activeSessionId)) 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().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 = beginVibeSession(seed);
startInFlight = serializeMaterial<StartedVibeSession>(async () => {
const started = await vibeService.start(seed.id);
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.setCurrentPlanItem(served.response.now);
vibe.setInitialBatchStatus('idle');
const playback = usePlaybackStore.getState();
playback.setVibeQueue([served.now, ...served.preview]);
playback.playTrack(served.now);
installVibeAdvanceHandler();
return { status: 'complete', tracks: [served.now, ...served.preview] };
});
try {
return await startInFlight;
} finally {
@@ -27,25 +419,18 @@ export async function startVibeSession(seed: Track): Promise<StartedVibeSession>
}
}
async function beginVibeSession(seed: Track): Promise<StartedVibeSession> {
const { sessionId } = await vibeService.start(seed.id);
const vibe = useVibeStore.getState();
// Do not replace a working Vibe until the new session has produced a usable
// initial batch. This also keeps the page prefetcher attached to the old
// session while this request is in flight.
const result = await fetchNextBatch(INITIAL_VIBE_BATCH_SIZE, sessionId);
if (result.tracks.length === 0) return result;
vibe.setInitialBatchStatus('loading');
vibe.setActiveSession({ sessionId, seedTrackId: seed.id });
vibe.setSeedTrackId(seed.id);
vibe.setCenterTrack(seed);
vibe.setBuffer(result.tracks);
vibe.setInitialBatchStatus('idle');
export async function endVibeSession(): Promise<void> {
return serializeMaterial(async () => {
const sessionId = useVibeStore.getState().activeSessionId;
try {
if (sessionId) await vibeService.end(sessionId);
} finally {
const playback = usePlaybackStore.getState();
playback.setQueue(result.tracks);
playback.playTrack(result.tracks[0]);
return result;
playback.setVibeAdvanceHandler(null);
useVibeStore.getState().reset();
playback.pause();
playback.setQueue([]);
playback.setCurrentTrack(null);
}
});
}
+44
View File
@@ -2,6 +2,8 @@ import { create } from 'zustand';
import type { Track } from '../types';
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
@@ -22,12 +24,22 @@ interface PlaybackState {
repeat: RepeatMode;
/** 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;
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;
@@ -71,6 +83,8 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
shuffle: false,
repeat: 'none',
shufflePlayed: new Set<string>(),
vibeAdvanceHandler: null,
queueOwner: 'ordinary',
setQueue: (queue) =>
set((state) => ({
@@ -78,6 +92,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 +120,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 +208,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;
+21 -23
View File
@@ -1,30 +1,34 @@
import { create } from 'zustand';
import type { Track, VibeSession } from '../types';
import type { VibePlanItem } from '../services/vibeService';
// 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;
centerTrack: Track | null;
buffer: Track[]; // lookahead buffer of upcoming recommended tracks
/** Outcome of the first V2 batch, including sessions initiated from Discover. */
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;
/** Returns false when a response belongs to an older plan revision. */
setPlan: (planVersion: number | null, preview: Track[]) => boolean;
setCurrentPlanItem: (item: VibePlanItem | null) => void;
setInitialBatchStatus: (status: VibeState['initialBatchStatus']) => void;
appendBuffer: (tracks: Track[]) => void;
shiftBuffer: () => Track | undefined;
reset: () => void;
}
const initialState = {
activeSessionId: null as string | null,
seedTrackId: null as string | null,
planVersion: null as number | null,
currentPlanItem: null as VibePlanItem | null,
centerTrack: null as Track | null,
buffer: [] as Track[],
initialBatchStatus: 'idle' as const,
@@ -33,26 +37,20 @@ const initialState = {
export const useVibeStore = create<VibeState>((set, get) => ({
...initialState,
setActiveSession: (session) =>
set(
setActiveSession: (session) => set(
session
? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId }
: { activeSessionId: null, seedTrackId: null }
: { activeSessionId: null, seedTrackId: null },
),
setSeedTrackId: (seedTrackId) => set({ seedTrackId }),
setCenterTrack: (centerTrack) => set({ centerTrack }),
setBuffer: (buffer) => set({ buffer }),
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
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 }),
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
reset: () => set({ ...initialState }),
}));