feat: enhance discovery, vibe sessions, and library enrichment
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
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,
|
||||
});
|
||||
|
||||
function responseError(status: number, code?: string) {
|
||||
return new AxiosError('request failed', undefined, undefined, undefined, {
|
||||
data: code ? { code } : {}, status, statusText: 'error', headers: {}, config: {} as never,
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -21,6 +22,13 @@ export interface VibeNextResponse {
|
||||
planRemaining: number;
|
||||
}
|
||||
|
||||
export type VibeBatchStatus = 'complete' | 'exhausted' | 'failed';
|
||||
|
||||
export interface VibeBatchResult {
|
||||
tracks: Track[];
|
||||
status: VibeBatchStatus;
|
||||
}
|
||||
|
||||
export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked';
|
||||
|
||||
// V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and
|
||||
@@ -35,16 +43,16 @@ export const vibeService = {
|
||||
// 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');
|
||||
async next(sessionId: string): Promise<VibeNextResponse> {
|
||||
const res = await api.get<VibeNextResponse>('/v2/vibe/next', { params: { 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 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 });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
@@ -58,16 +66,23 @@ export const vibeService = {
|
||||
|
||||
// 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[]> {
|
||||
// 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();
|
||||
const { track } = await vibeService.next(sessionId);
|
||||
tracks.push(track);
|
||||
} catch {
|
||||
break;
|
||||
} 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;
|
||||
return { tracks, status: 'complete' };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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 };
|
||||
},
|
||||
}));
|
||||
|
||||
import { 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,
|
||||
});
|
||||
|
||||
describe('startVibeSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useVibeStore.getState().reset();
|
||||
usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false });
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ tracks: [], status: 'failed' });
|
||||
expect(useVibeStore.getState().activeSessionId).toBe('old-session');
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('old');
|
||||
});
|
||||
|
||||
it('serializes rapid starts and hydrates only one session', async () => {
|
||||
const recommended = track('recommended');
|
||||
start.mockResolvedValue({ sessionId: 'session-a', plan: [] });
|
||||
next.mockResolvedValue({ track: recommended });
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import { fetchNextBatch, vibeService, type VibeBatchStatus } from './vibeService';
|
||||
|
||||
export const INITIAL_VIBE_BATCH_SIZE = 5;
|
||||
|
||||
export interface StartedVibeSession {
|
||||
status: VibeBatchStatus;
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
let startInFlight: Promise<StartedVibeSession> | null = null;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function startVibeSession(seed: Track): Promise<StartedVibeSession> {
|
||||
if (startInFlight) return startInFlight;
|
||||
startInFlight = beginVibeSession(seed);
|
||||
try {
|
||||
return await startInFlight;
|
||||
} finally {
|
||||
startInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setQueue(result.tracks);
|
||||
playback.playTrack(result.tracks[0]);
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user