fix vibe engine audit findings: pg.Pool, plan replan, dead exclusions, legacy engine removal
Backend: - app.ts: switch shared pg.Client to pg.Pool with per-transaction clients (#205) - v2.routes.ts: replace plan instead of appending on replan, fixing self-duplication (#206) - session-director: populate recentExclusions, per-candidate ranking, batch repetition checks (#209/#211/#213/#215 + minor) - db.service.ts: claim-fusion watermark, legacy recommendation_batch engine removed (#216/#219/#232) - app.ts: drop test enqueue-job endpoint (#234) Frontend: - AudioEngine/Vibe/usePlaybackStore: dedupe completed feedback, gate feedback to vibe sessions, End Vibe stops playback, Keep toast, shuffle played-set (#207/#236/#237/#238/#239/#240) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+15
-28
@@ -1,6 +1,6 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { Client as PgClient } from 'pg';
|
||||
import { Pool } from 'pg';
|
||||
import { createClient as createRedisClient } from 'redis';
|
||||
import { DbService } from './services/db.service.js';
|
||||
import { JobService } from './services/job.service.js';
|
||||
@@ -8,7 +8,6 @@ import { SearchService } from './services/search.service.js';
|
||||
import libraryRoutes from './routes/library.routes.js';
|
||||
import searchRoutes from './routes/search.routes.js';
|
||||
import adminRoutes from './routes/admin.routes.js';
|
||||
import vibeRoutes from './routes/vibe.routes.js';
|
||||
import historyRoutes from './routes/history.routes.js';
|
||||
import streamRoutes from './routes/stream.routes.js';
|
||||
import quarantineRoutes from './routes/quarantine.routes.js';
|
||||
@@ -33,10 +32,14 @@ export async function buildApp(config: AppConfig) {
|
||||
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:5173', 'http://localhost:5174'],
|
||||
});
|
||||
|
||||
const pgClient = new PgClient({
|
||||
// A Pool (not a single Client) so concurrent requests get independent
|
||||
// connections. Critical for correctness: recordPlay/recordSkip run
|
||||
// BEGIN...COMMIT transactions, and other requests' queries must not be
|
||||
// able to land inside another request's open transaction on a shared
|
||||
// connection (see db.service.ts withTransaction()).
|
||||
const pgPool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
await pgClient.connect();
|
||||
|
||||
const redisClient = createRedisClient({
|
||||
url: process.env.REDIS_URL,
|
||||
@@ -50,7 +53,7 @@ export async function buildApp(config: AppConfig) {
|
||||
protocol: 'http',
|
||||
apiKey: config.searchApiKey,
|
||||
});
|
||||
const dbService = new DbService(pgClient, searchService);
|
||||
const dbService = new DbService(pgPool, searchService);
|
||||
|
||||
// Apply the idempotent schema on boot so tables added after the initial DB
|
||||
// volume was created (e.g. play_history, feedback) exist. The init-time
|
||||
@@ -118,7 +121,7 @@ export async function buildApp(config: AppConfig) {
|
||||
};
|
||||
|
||||
try {
|
||||
await pgClient.query('SELECT 1');
|
||||
await pgPool.query('SELECT 1');
|
||||
status.postgres = 'ok';
|
||||
} catch (err) {
|
||||
status.postgres = 'error';
|
||||
@@ -136,7 +139,7 @@ export async function buildApp(config: AppConfig) {
|
||||
}
|
||||
|
||||
const isHealthy = status.postgres === 'ok' && status.redis === 'ok';
|
||||
|
||||
|
||||
if (isHealthy) {
|
||||
return reply.code(200).send(status);
|
||||
} else {
|
||||
@@ -148,7 +151,6 @@ export async function buildApp(config: AppConfig) {
|
||||
fastify.register(libraryRoutes, { prefix: '/api', dbService });
|
||||
fastify.register(searchRoutes, { prefix: '/api', dbService });
|
||||
fastify.register(adminRoutes, { prefix: '/api/admin', jobService, dbService });
|
||||
fastify.register(vibeRoutes, { prefix: '/api/vibe', dbService });
|
||||
fastify.register(historyRoutes, { prefix: '/api', dbService });
|
||||
fastify.register(streamRoutes, { prefix: '/api', dbService });
|
||||
fastify.register(quarantineRoutes, { prefix: '/api', dbService });
|
||||
@@ -159,24 +161,9 @@ export async function buildApp(config: AppConfig) {
|
||||
|
||||
fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector });
|
||||
fastify.register(discoveryRoutes, { prefix: '/api', dbService });
|
||||
fastify.post('/api/test/enqueue-job', async (request, reply) => {
|
||||
const { jobType, trackId, payload } = request.body as any;
|
||||
try {
|
||||
if (jobType === 'metadataRefresh') {
|
||||
await jobService.enqueueMetadataRefresh(trackId, payload.type);
|
||||
} else if (jobType === 'audioAnalysis') {
|
||||
await jobService.enqueueAudioAnalysis(trackId, payload.features);
|
||||
} else if (jobType === 'cleanup') {
|
||||
await jobService.enqueueCleanup(payload.reason, payload.targetFiles);
|
||||
} else {
|
||||
return reply.code(400).send({ error: 'Unknown job type' });
|
||||
}
|
||||
await reply.send({ message: 'Job enqueued' });
|
||||
} catch (error) {
|
||||
request.log.error(error);
|
||||
await reply.status(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
// ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed —
|
||||
// nothing in the deployed app or its tests called it, and deployment never
|
||||
// sets NODE_ENV so an env gate would've stayed live in prod anyway.
|
||||
|
||||
// Register hooks to close connections on shutdown
|
||||
fastify.addHook('onClose', async () => {
|
||||
@@ -188,7 +175,7 @@ export async function buildApp(config: AppConfig) {
|
||||
fastify.log.error(err);
|
||||
}
|
||||
try {
|
||||
await pgClient.end();
|
||||
await pgPool.end();
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
}
|
||||
@@ -204,5 +191,5 @@ export async function buildApp(config: AppConfig) {
|
||||
}
|
||||
});
|
||||
|
||||
return { fastify, pgClient, redisClient };
|
||||
return { fastify, pgPool, redisClient };
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
||||
// Replan if running low
|
||||
if (active.plan.length < 5) {
|
||||
const refill = await director.replan(userId, active.sessionId, active.plan, [next.trackId], active.seedTrackId ?? undefined);
|
||||
active.plan.push(...refill);
|
||||
active.plan = refill;
|
||||
}
|
||||
|
||||
await setActivePlan(userId, active);
|
||||
@@ -111,7 +111,7 @@ export default async function v2Routes(fastify: FastifyInstance, options: { dbSe
|
||||
if (active) {
|
||||
const playedTrackIds = [trackId];
|
||||
const refill = await director.replan(userId, active.sessionId, active.plan, playedTrackIds, active.seedTrackId ?? undefined);
|
||||
active.plan.push(...refill);
|
||||
active.plan = refill;
|
||||
await setActivePlan(userId, active);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { DbService } from '../services/db.service.js';
|
||||
|
||||
export default async function vibeRoutes(fastify: FastifyInstance, options: { dbService: DbService }) {
|
||||
const { dbService } = options;
|
||||
|
||||
// Start a new vibe session
|
||||
fastify.post('/start', async (request, reply) => {
|
||||
const { seedTrackId } = request.body as { seedTrackId: string };
|
||||
if (!seedTrackId) {
|
||||
return reply.code(400).send({ error: 'seedTrackId is required' });
|
||||
}
|
||||
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const batchId = await dbService.createVibeSession(userId, seedTrackId);
|
||||
return reply.send({ batchId });
|
||||
});
|
||||
|
||||
// Get the next chunk of tracks for the active session
|
||||
fastify.get('/next', async (request, reply) => {
|
||||
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const activeSession = await dbService.getActiveVibeSession(userId);
|
||||
|
||||
if (!activeSession) {
|
||||
return reply.code(404).send({ error: 'No active vibe session found' });
|
||||
}
|
||||
|
||||
const tracks = await dbService.getNextVibeChunk(activeSession.batchId);
|
||||
|
||||
// Update the session timestamp to keep it alive
|
||||
await dbService.updateVibeSession(activeSession.batchId);
|
||||
|
||||
return tracks;
|
||||
});
|
||||
|
||||
// Start/return a chunk seeded by a genre (id or name) — no active session required.
|
||||
fastify.get('/from-genre', async (request, reply) => {
|
||||
const { genre } = request.query as { genre?: string };
|
||||
if (!genre) {
|
||||
return reply.code(400).send({ error: 'genre query param is required' });
|
||||
}
|
||||
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const tracks = await dbService.getVibeChunkFromGenre(genre, userId);
|
||||
return tracks;
|
||||
});
|
||||
|
||||
// Current ACTIVE batch metadata for the user, or 404.
|
||||
fastify.get('/current', async (request, reply) => {
|
||||
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const session = await dbService.getCurrentVibeSession(userId);
|
||||
if (!session) {
|
||||
return reply.code(404).send({ error: 'No active vibe session found' });
|
||||
}
|
||||
return reply.send(session);
|
||||
});
|
||||
|
||||
// Heartbeat: keep the active batch alive by bumping last_interaction_at.
|
||||
fastify.post('/heartbeat', async (request, reply) => {
|
||||
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const updated = await dbService.heartbeatVibeSession(userId);
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: 'No active vibe session found' });
|
||||
}
|
||||
return reply.send({ status: 'ok' });
|
||||
});
|
||||
|
||||
// End the vibe session
|
||||
fastify.post('/end', async (request, reply) => {
|
||||
const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
|
||||
const activeSession = await dbService.getActiveVibeSession(userId);
|
||||
|
||||
if (activeSession) {
|
||||
await dbService.endVibeSession(activeSession.batchId);
|
||||
}
|
||||
return reply.send({ status: 'session_ended' });
|
||||
});
|
||||
}
|
||||
+163
-666
File diff suppressed because it is too large
Load Diff
@@ -178,7 +178,7 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
|
||||
const maxCandidates = Math.max(1, Math.floor(10 * noveltyTolerance));
|
||||
|
||||
const unfamiliarRes = await db.pgClient.query(
|
||||
`SELECT DISTINCT cf.object_id AS artist_id
|
||||
`SELECT cf.object_id AS artist_id, MAX(cf.fused_value) AS edge_strength
|
||||
FROM claim_fusion cf
|
||||
WHERE cf.subject_type = 'artist'
|
||||
AND cf.subject_id = ANY($1::uuid[])
|
||||
@@ -191,16 +191,19 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
|
||||
AND lb.entity_id = cf.object_id
|
||||
AND lb.profile IN ('longterm', 'obsession')
|
||||
)
|
||||
GROUP BY cf.object_id
|
||||
LIMIT 30`,
|
||||
[trustedIds, ctx.userId]
|
||||
);
|
||||
|
||||
const unfamiliarArtistIds = (unfamiliarRes.rows as { artist_id: string }[]).map(r => r.artist_id);
|
||||
const unfamiliarRows = unfamiliarRes.rows as { artist_id: string; edge_strength: number }[];
|
||||
const unfamiliarArtistIds = unfamiliarRows.map(r => r.artist_id);
|
||||
const edgeStrengthMap = new Map(unfamiliarRows.map(r => [r.artist_id, r.edge_strength]));
|
||||
if (unfamiliarArtistIds.length === 0) return [];
|
||||
|
||||
const trackRes = await db.pgClient.query(
|
||||
`SELECT id FROM (
|
||||
SELECT DISTINCT t.id
|
||||
`SELECT id, artist_id FROM (
|
||||
SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_id
|
||||
FROM tracks t
|
||||
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
|
||||
AND cf.predicate IN ('credited_main_on', 'featured_on')
|
||||
@@ -208,25 +211,29 @@ async function discoveryGenerator(db: DbService, ctx: GeneratorContext): Promise
|
||||
AND cf.object_id = ANY($1::uuid[])
|
||||
WHERE t.state = 'LIBRARY'
|
||||
AND NOT (t.id = ANY($2::uuid[]))
|
||||
ORDER BY t.id, cf.fused_value DESC NULLS LAST
|
||||
) sub
|
||||
ORDER BY RANDOM()
|
||||
LIMIT $3`,
|
||||
[unfamiliarArtistIds, ctx.recentExclusions, maxCandidates]
|
||||
);
|
||||
|
||||
return (trackRes.rows as { id: string }[]).map(row => ({
|
||||
trackId: row.id,
|
||||
generatorId: 'discovery',
|
||||
explanation: [{
|
||||
subjectType: 'artist',
|
||||
subjectId: unfamiliarArtistIds[0],
|
||||
predicate: 'credited_main_on',
|
||||
objectType: 'track',
|
||||
objectId: row.id,
|
||||
fusedValue: 0.4,
|
||||
}],
|
||||
relevance: 0.4,
|
||||
}));
|
||||
return (trackRes.rows as { id: string; artist_id: string }[]).map(row => {
|
||||
const relevance = edgeStrengthMap.get(row.artist_id) ?? 0.4;
|
||||
return {
|
||||
trackId: row.id,
|
||||
generatorId: 'discovery',
|
||||
explanation: [{
|
||||
subjectType: 'artist',
|
||||
subjectId: row.artist_id,
|
||||
predicate: 'credited_main_on',
|
||||
objectType: 'track',
|
||||
objectId: row.id,
|
||||
fusedValue: relevance,
|
||||
}],
|
||||
relevance,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -253,6 +260,11 @@ async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<
|
||||
|
||||
const albumIds = albumRows.map(a => a.album_id);
|
||||
const albumArtistMap = new Map(albumRows.map(a => [a.album_id, a.artist_id]));
|
||||
const obsessionValueMap = new Map(
|
||||
ctx.beliefs
|
||||
.filter(b => b.profile === 'obsession' && b.entity_type === 'artist' && b.dimension === 'affinity' && b.value > 0.3)
|
||||
.map(b => [b.entity_id, b.value])
|
||||
);
|
||||
|
||||
const trackRes = await db.pgClient.query(
|
||||
`SELECT sub.id, sub.album_id
|
||||
@@ -269,19 +281,23 @@ async function deepDiveGenerator(db: DbService, ctx: GeneratorContext): Promise<
|
||||
[albumIds, ctx.recentExclusions]
|
||||
);
|
||||
|
||||
return (trackRes.rows as { id: string; album_id: string }[]).map(row => ({
|
||||
trackId: row.id,
|
||||
generatorId: 'deep-dive',
|
||||
explanation: [{
|
||||
subjectType: 'artist',
|
||||
subjectId: albumArtistMap.get(row.album_id) ?? 'unknown',
|
||||
predicate: 'credited_main_on',
|
||||
objectType: 'track',
|
||||
objectId: row.id,
|
||||
fusedValue: 0.7,
|
||||
}],
|
||||
relevance: 0.7,
|
||||
}));
|
||||
return (trackRes.rows as { id: string; album_id: string }[]).map(row => {
|
||||
const artistId = albumArtistMap.get(row.album_id) ?? 'unknown';
|
||||
const relevance = obsessionValueMap.get(artistId) ?? 0.7;
|
||||
return {
|
||||
trackId: row.id,
|
||||
generatorId: 'deep-dive',
|
||||
explanation: [{
|
||||
subjectType: 'artist',
|
||||
subjectId: artistId,
|
||||
predicate: 'credited_main_on',
|
||||
objectType: 'track',
|
||||
objectId: row.id,
|
||||
fusedValue: relevance,
|
||||
}],
|
||||
relevance,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -306,10 +322,11 @@ async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<C
|
||||
if (staleArtists.length === 0) return [];
|
||||
|
||||
const staleArtistIds = staleArtists.map(a => a.artist_id);
|
||||
const affinityMap = new Map(staleArtists.map(a => [a.artist_id, a.affinity]));
|
||||
|
||||
const trackRes = await db.pgClient.query(
|
||||
`SELECT id FROM (
|
||||
SELECT DISTINCT t.id
|
||||
`SELECT id, artist_id FROM (
|
||||
SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_id
|
||||
FROM tracks t
|
||||
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
|
||||
AND cf.predicate IN ('credited_main_on', 'featured_on')
|
||||
@@ -318,25 +335,29 @@ async function revivalGenerator(db: DbService, ctx: GeneratorContext): Promise<C
|
||||
AND (cf.user_id = $2 OR cf.user_id = $3)
|
||||
WHERE t.state = 'LIBRARY'
|
||||
AND NOT (t.id = ANY($4::uuid[]))
|
||||
ORDER BY t.id, cf.fused_value DESC NULLS LAST
|
||||
) sub
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 20`,
|
||||
[staleArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
|
||||
);
|
||||
|
||||
return (trackRes.rows as { id: string }[]).map(row => ({
|
||||
trackId: row.id,
|
||||
generatorId: 'revival',
|
||||
explanation: [{
|
||||
subjectType: 'artist',
|
||||
subjectId: staleArtistIds[0],
|
||||
predicate: 'credited_main_on',
|
||||
objectType: 'track',
|
||||
objectId: row.id,
|
||||
fusedValue: 0.6,
|
||||
}],
|
||||
relevance: 0.6,
|
||||
}));
|
||||
return (trackRes.rows as { id: string; artist_id: string }[]).map(row => {
|
||||
const relevance = affinityMap.get(row.artist_id) ?? 0.6;
|
||||
return {
|
||||
trackId: row.id,
|
||||
generatorId: 'revival',
|
||||
explanation: [{
|
||||
subjectType: 'artist',
|
||||
subjectId: row.artist_id,
|
||||
predicate: 'credited_main_on',
|
||||
objectType: 'track',
|
||||
objectId: row.id,
|
||||
fusedValue: relevance,
|
||||
}],
|
||||
relevance,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -455,15 +476,15 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
|
||||
order: 'DESC',
|
||||
});
|
||||
|
||||
const targetArtistIds = contextualBeliefs
|
||||
.filter(b => b.entity_type === 'artist' && b.value > 0.2)
|
||||
.map(b => b.entity_id);
|
||||
const targetBeliefs = contextualBeliefs.filter(b => b.entity_type === 'artist' && b.value > 0.2);
|
||||
const targetArtistIds = targetBeliefs.map(b => b.entity_id);
|
||||
const targetValueMap = new Map(targetBeliefs.map(b => [b.entity_id, b.value]));
|
||||
|
||||
if (targetArtistIds.length === 0) return [];
|
||||
|
||||
const trackRes = await db.pgClient.query(
|
||||
`SELECT id FROM (
|
||||
SELECT DISTINCT t.id
|
||||
`SELECT id, artist_id FROM (
|
||||
SELECT DISTINCT ON (t.id) t.id, cf.object_id AS artist_id
|
||||
FROM tracks t
|
||||
JOIN claim_fusion cf ON cf.subject_type = 'track' AND cf.subject_id = t.id
|
||||
AND cf.predicate IN ('credited_main_on', 'featured_on')
|
||||
@@ -472,25 +493,29 @@ async function contextualGenerator(db: DbService, ctx: GeneratorContext): Promis
|
||||
AND (cf.user_id = $2 OR cf.user_id = $3)
|
||||
WHERE t.state = 'LIBRARY'
|
||||
AND NOT (t.id = ANY($4::uuid[]))
|
||||
ORDER BY t.id, cf.fused_value DESC NULLS LAST
|
||||
) sub
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 15`,
|
||||
[targetArtistIds, OBJECTIVE_USER, ctx.userId, ctx.recentExclusions]
|
||||
);
|
||||
|
||||
return (trackRes.rows as { id: string }[]).map(row => ({
|
||||
trackId: row.id,
|
||||
generatorId: 'contextual',
|
||||
explanation: [{
|
||||
subjectType: 'artist',
|
||||
subjectId: targetArtistIds[0],
|
||||
predicate: 'credited_main_on',
|
||||
objectType: 'track',
|
||||
objectId: row.id,
|
||||
fusedValue: 0.5,
|
||||
}],
|
||||
relevance: 0.5,
|
||||
}));
|
||||
return (trackRes.rows as { id: string; artist_id: string }[]).map(row => {
|
||||
const relevance = targetValueMap.get(row.artist_id) ?? 0.5;
|
||||
return {
|
||||
trackId: row.id,
|
||||
generatorId: 'contextual',
|
||||
explanation: [{
|
||||
subjectType: 'artist',
|
||||
subjectId: row.artist_id,
|
||||
predicate: 'credited_main_on',
|
||||
objectType: 'track',
|
||||
objectId: row.id,
|
||||
fusedValue: relevance,
|
||||
}],
|
||||
relevance,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -28,6 +28,11 @@ export interface DiversityBudget {
|
||||
spent: number;
|
||||
}
|
||||
|
||||
export interface RepetitionState {
|
||||
recentTrackIds: Set<string>;
|
||||
recentArtistIds: Set<string>;
|
||||
}
|
||||
|
||||
const W_ENJOY = 1.0;
|
||||
const W_FATIGUE = 0.4;
|
||||
const W_DIVERSITY = 0.3;
|
||||
@@ -152,7 +157,7 @@ export class SessionDirector {
|
||||
// D.2 — Fatigue model
|
||||
// ---------------------------------------------------------------
|
||||
async computeFatigue(userId: string): Promise<FatigueState> {
|
||||
// Track fatigue: last 7 days, decay half-life 30d (2592000 seconds)
|
||||
// Track fatigue: last 7 days, decay time constant 30d (e-folding; half-life ≈ 20.8d)
|
||||
const TRACK_DECAY_SEC = 30 * 24 * 3600;
|
||||
const trackRes = await this.db.pgClient.query(
|
||||
`SELECT ph.track_id,
|
||||
@@ -167,7 +172,8 @@ export class SessionDirector {
|
||||
track.set(row.track_id, row.fatigue);
|
||||
}
|
||||
|
||||
// Artist fatigue: last 24h, decay half-life 8h (28800 seconds)
|
||||
// Artist fatigue: last 24h, decay time constant 8h (28800s) — this is an e-folding
|
||||
// time (EXP(-t/tau)), not a half-life; the actual half-life is tau*ln(2) ≈ 5.5h
|
||||
const ARTIST_DECAY_SEC = 8 * 3600;
|
||||
const artistRes = await this.db.pgClient.query(
|
||||
`SELECT ta.artist_id,
|
||||
@@ -183,7 +189,7 @@ export class SessionDirector {
|
||||
artist.set(row.artist_id, row.fatigue);
|
||||
}
|
||||
|
||||
// Genre fatigue: last 24h, decay half-life 8h
|
||||
// Genre fatigue: last 24h, decay time constant 8h (e-folding, not half-life; half-life ≈ 5.5h)
|
||||
const genreRes = await this.db.pgClient.query(
|
||||
`SELECT tg.genre_id,
|
||||
LEAST(1.0, SUM(EXP(-EXTRACT(EPOCH FROM (NOW() - ph.played_at)) / $2::float8))) AS fatigue
|
||||
@@ -198,7 +204,7 @@ export class SessionDirector {
|
||||
genre.set(row.genre_id, row.fatigue);
|
||||
}
|
||||
|
||||
// Language fatigue: last 2h, decay half-life 1h (3600 seconds)
|
||||
// Language fatigue: last 2h, decay time constant 1h (3600s, e-folding; half-life ≈ 0.7h)
|
||||
const LANG_DECAY_SEC = 3600;
|
||||
const langRes = await this.db.pgClient.query(
|
||||
`SELECT tl.language,
|
||||
@@ -427,14 +433,25 @@ export class SessionDirector {
|
||||
// ---------------------------------------------------------------
|
||||
// D.5 — Entropy, anti-loop
|
||||
// ---------------------------------------------------------------
|
||||
computeEntropy(candidates: Candidate[]): number {
|
||||
// NOTE: despite the name, this computes the Herfindahl-Hirschman Index (artist
|
||||
// concentration, 0 = maximally diverse, 1 = single artist) — not entropy.
|
||||
// `artistIdOf` should resolve the candidate's actual attributed artist; without it
|
||||
// this falls back to guessing from the first artist-typed explanation edge, which
|
||||
// for some generators (discovery/revival/contextual) isn't the real artist —
|
||||
// pass a resolver when a real artist map is available (see rankCandidates).
|
||||
computeEntropy(candidates: Candidate[], artistIdOf?: (c: Candidate) => string): number {
|
||||
if (candidates.length === 0) return 0;
|
||||
const artistCounts = new Map<string, number>();
|
||||
for (const c of candidates) {
|
||||
const mainEdge = c.explanation.find(
|
||||
e => e.subjectType === 'artist' || e.objectType === 'artist'
|
||||
);
|
||||
const key = mainEdge?.subjectId ?? mainEdge?.objectId ?? 'unknown';
|
||||
let key: string;
|
||||
if (artistIdOf) {
|
||||
key = artistIdOf(c);
|
||||
} else {
|
||||
const mainEdge = c.explanation.find(
|
||||
e => e.subjectType === 'artist' || e.objectType === 'artist'
|
||||
);
|
||||
key = mainEdge?.subjectId ?? mainEdge?.objectId ?? 'unknown';
|
||||
}
|
||||
artistCounts.set(key, (artistCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
const n = candidates.length;
|
||||
@@ -599,6 +616,61 @@ export class SessionDirector {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Batched version of checkRepetition for ranking a whole candidate pool:
|
||||
// loads repetition_rules once, then one query for recently-played
|
||||
// tracks/artists within the max window, and checks membership in JS
|
||||
// instead of 2-3 sequential queries per candidate.
|
||||
async buildRepetitionState(userId: string): Promise<RepetitionState> {
|
||||
const rulesRes = await this.db.pgClient.query(
|
||||
'SELECT dimension, min_distance FROM repetition_rules WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
const ruleMap = new Map<string, number>();
|
||||
for (const row of rulesRes.rows as { dimension: string; min_distance: number }[]) {
|
||||
ruleMap.set(row.dimension, row.min_distance);
|
||||
}
|
||||
const trackMin = ruleMap.get('track') ?? 120;
|
||||
const artistMin = ruleMap.get('artist') ?? 20;
|
||||
|
||||
const recentTrackIds = new Set<string>();
|
||||
const recentArtistIds = new Set<string>();
|
||||
const maxMin = Math.max(trackMin, artistMin);
|
||||
if (maxMin > 0) {
|
||||
const res = await this.db.pgClient.query(
|
||||
`SELECT ph.track_id, ta.artist_id, ph.played_at
|
||||
FROM play_history ph
|
||||
LEFT JOIN track_artists_v2 ta ON ta.track_id = ph.track_id AND ta.role = 'main'
|
||||
WHERE ph.user_id = $1 AND ph.completed = true
|
||||
AND ph.played_at > NOW() - ($2 || ' minutes')::interval`,
|
||||
[userId, String(maxMin)]
|
||||
);
|
||||
const now = Date.now();
|
||||
for (const row of res.rows as { track_id: string; artist_id: string | null; played_at: Date }[]) {
|
||||
const ageMin = (now - new Date(row.played_at).getTime()) / 60000;
|
||||
if (trackMin > 0 && ageMin <= trackMin) recentTrackIds.add(row.track_id);
|
||||
if (artistMin > 0 && row.artist_id && ageMin <= artistMin) recentArtistIds.add(row.artist_id);
|
||||
}
|
||||
}
|
||||
|
||||
return { recentTrackIds, recentArtistIds };
|
||||
}
|
||||
|
||||
// Shared track_id -> main artist_id lookup, used by rankCandidates and replan.
|
||||
private async loadArtistMap(trackIds: string[]): Promise<Map<string, string>> {
|
||||
const artistMap = new Map<string, string>();
|
||||
if (trackIds.length === 0) return artistMap;
|
||||
const artRes = await this.db.pgClient.query(
|
||||
`SELECT DISTINCT ON (ta.track_id) ta.track_id, ta.artist_id
|
||||
FROM track_artists_v2 ta
|
||||
WHERE ta.track_id = ANY($1::uuid[]) AND ta.role = 'main'`,
|
||||
[trackIds]
|
||||
);
|
||||
for (const row of artRes.rows as { track_id: string; artist_id: string }[]) {
|
||||
artistMap.set(row.track_id, row.artist_id);
|
||||
}
|
||||
return artistMap;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// D.8 — Multi-objective ranking
|
||||
// ---------------------------------------------------------------
|
||||
@@ -607,23 +679,12 @@ export class SessionDirector {
|
||||
fatigue: FatigueState,
|
||||
budgets: DiversityBudget[],
|
||||
state: GeneratorContext['state'],
|
||||
repetitionCheck: (trackId: string, artistId: string) => Promise<boolean>
|
||||
repetitionState: RepetitionState
|
||||
): Promise<Candidate[]> {
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const trackIds = [...new Set(candidates.map(c => c.trackId))];
|
||||
const artistMap = new Map<string, string>();
|
||||
if (trackIds.length > 0) {
|
||||
const artRes = await this.db.pgClient.query(
|
||||
`SELECT DISTINCT ON (ta.track_id) ta.track_id, ta.artist_id
|
||||
FROM track_artists_v2 ta
|
||||
WHERE ta.track_id = ANY($1::uuid[]) AND ta.role = 'main'`,
|
||||
[trackIds]
|
||||
);
|
||||
for (const row of artRes.rows as { track_id: string; artist_id: string }[]) {
|
||||
artistMap.set(row.track_id, row.artist_id);
|
||||
}
|
||||
}
|
||||
const artistMap = await this.loadArtistMap(trackIds);
|
||||
|
||||
const genreMap = new Map<string, string>();
|
||||
if (trackIds.length > 0) {
|
||||
@@ -639,10 +700,17 @@ export class SessionDirector {
|
||||
}
|
||||
}
|
||||
|
||||
const artistBudget = budgets.find(b => b.dimension === 'artist');
|
||||
const currentEntropy = this.computeEntropy(candidates);
|
||||
const currentEntropy = this.computeEntropy(candidates, c => artistMap.get(c.trackId) ?? 'unknown');
|
||||
const targetEntropy = 0.55;
|
||||
|
||||
// Per-candidate artist share within this batch, for a real per-candidate
|
||||
// entropy contribution instead of the batch-wide constant.
|
||||
const artistBatchCounts = new Map<string, number>();
|
||||
for (const c of candidates) {
|
||||
const aid = artistMap.get(c.trackId) ?? '';
|
||||
artistBatchCounts.set(aid, (artistBatchCounts.get(aid) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const scored: { candidate: Candidate; score: number }[] = [];
|
||||
for (const c of candidates) {
|
||||
const artistId = artistMap.get(c.trackId) ?? '';
|
||||
@@ -653,10 +721,14 @@ export class SessionDirector {
|
||||
const genreFatigue = fatigue.genre.get(genreId) ?? 0;
|
||||
const avgFatigue = (trackFatigue + artistFatigue + genreFatigue) / 3;
|
||||
|
||||
const artistSpendRatio = artistBudget ? artistBudget.spent : 0;
|
||||
const diversityBonus = 1 - artistSpendRatio;
|
||||
const entropyBonus = 1 - Math.abs(currentEntropy - targetEntropy);
|
||||
const wouldRepeat = await repetitionCheck(c.trackId, artistId);
|
||||
// diversityBonus: this artist's own fatigue-weighted share — varies per candidate.
|
||||
const diversityBonus = 1 - artistFatigue;
|
||||
// entropyBonus: reward candidates whose artist is underrepresented in this batch.
|
||||
const artistShare = (artistBatchCounts.get(artistId) ?? 0) / candidates.length;
|
||||
const entropyBonus = 1 - artistShare;
|
||||
const wouldRepeat =
|
||||
repetitionState.recentTrackIds.has(c.trackId) ||
|
||||
(!!artistId && repetitionState.recentArtistIds.has(artistId));
|
||||
|
||||
let score = W_ENJOY * c.relevance
|
||||
- W_FATIGUE * avgFatigue
|
||||
@@ -687,6 +759,27 @@ export class SessionDirector {
|
||||
return scored.map(s => s.candidate);
|
||||
}
|
||||
|
||||
// session_state is otherwise only written once at /v2/vibe/start — persist the
|
||||
// freshly-computed state vector here so it evolves across the session instead of
|
||||
// buildState always reading back the boot defaults.
|
||||
async persistState(sessionId: string, userId: string, state: GeneratorContext['state']): Promise<void> {
|
||||
await this.db.pgClient.query(
|
||||
`UPDATE session_state
|
||||
SET state_vector = $3::jsonb, last_interaction = NOW()
|
||||
WHERE session_id = $1 AND user_id = $2`,
|
||||
[
|
||||
sessionId,
|
||||
userId,
|
||||
JSON.stringify({
|
||||
energy: state.energy,
|
||||
lastArtistIds: state.lastArtistIds,
|
||||
lastGenreIds: state.lastGenreIds,
|
||||
noveltyHunger: state.noveltyHunger,
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// D.9 — Plan + replan loop
|
||||
// ---------------------------------------------------------------
|
||||
@@ -725,6 +818,7 @@ export class SessionDirector {
|
||||
}));
|
||||
|
||||
const state = await this.buildState(userId, sessionId);
|
||||
await this.persistState(sessionId, userId, state);
|
||||
const fatigue = await this.computeFatigue(userId);
|
||||
const budgets = await this.getBudgets(userId);
|
||||
|
||||
@@ -737,7 +831,7 @@ export class SessionDirector {
|
||||
seedArtistId = await this.resolveSeedArtistId(seedTrackId) ?? null;
|
||||
}
|
||||
|
||||
const recentExclusions: string[] = [];
|
||||
const recentExclusions: string[] = recentPlays.map(p => p.trackId);
|
||||
const toleranceMap: Record<string, number> = {};
|
||||
const discoveryBeliefs = allBeliefs.filter(b => b.profile === 'discovery');
|
||||
for (const b of discoveryBeliefs) {
|
||||
@@ -764,28 +858,17 @@ export class SessionDirector {
|
||||
return [];
|
||||
}
|
||||
|
||||
const repetitionCheckFn = (tid: string, aid: string) =>
|
||||
this.checkRepetition(tid, aid, userId);
|
||||
const repetitionState = await this.buildRepetitionState(userId);
|
||||
const ranked = await this.rankCandidates(
|
||||
allCandidates, fatigue, budgets, state, repetitionCheckFn
|
||||
allCandidates, fatigue, budgets, state, repetitionState
|
||||
);
|
||||
|
||||
const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays);
|
||||
let forcedExperimental = false;
|
||||
if (loopDim && ranked.length > 0) {
|
||||
const expCtx: GeneratorContext = {
|
||||
...ctx,
|
||||
recentExclusions: ctx.recentExclusions.slice(0, Math.min(ctx.recentExclusions.length, 50)),
|
||||
};
|
||||
const extraCandidates: Candidate[] = [];
|
||||
for (const gen of ALL_GENERATORS) {
|
||||
const result = await gen(this.db, expCtx);
|
||||
extraCandidates.push(...result);
|
||||
}
|
||||
const expRanked = await this.rankCandidates(
|
||||
extraCandidates, fatigue, budgets, state, repetitionCheckFn
|
||||
);
|
||||
const injected = expRanked.filter(
|
||||
// Anti-loop candidates are already present in `ranked` — just pull them to the
|
||||
// front instead of re-running all generators and re-ranking from scratch.
|
||||
const injected = ranked.filter(
|
||||
c => c.generatorId === 'experimental' || c.generatorId === 'discovery'
|
||||
);
|
||||
ranked.unshift(...injected);
|
||||
@@ -855,6 +938,7 @@ export class SessionDirector {
|
||||
const fatigue = await this.computeFatigue(userId);
|
||||
const budgets = await this.getBudgets(userId);
|
||||
const state = await this.buildState(userId, sessionId);
|
||||
await this.persistState(sessionId, userId, state);
|
||||
|
||||
// Fetch recent plays for anti-loop
|
||||
const recentPlaysRes = await this.db.pgClient.query(
|
||||
@@ -892,7 +976,8 @@ export class SessionDirector {
|
||||
return this.buildPlan(userId, sessionId, seedTrackId);
|
||||
}
|
||||
|
||||
const entropy = this.computeEntropy(currentPlan);
|
||||
const planArtistMap = await this.loadArtistMap([...new Set(currentPlan.map(c => c.trackId))]);
|
||||
const entropy = this.computeEntropy(currentPlan, c => planArtistMap.get(c.trackId) ?? 'unknown');
|
||||
if (Math.abs(entropy - 0.55) > 0.2) {
|
||||
return this.buildPlan(userId, sessionId, seedTrackId);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ describe('SessionDirector', () => {
|
||||
const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }];
|
||||
const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
|
||||
|
||||
const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, async () => false);
|
||||
const repetitionState = { recentTrackIds: new Set<string>(), recentArtistIds: new Set<string>() };
|
||||
const ranked = await director.rankCandidates(candidates, fatigue, budgets, state, repetitionState);
|
||||
expect(ranked[0].relevance).toBeGreaterThanOrEqual(ranked[ranked.length - 1].relevance);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
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 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.
|
||||
@@ -39,9 +48,6 @@ export const AudioEngine = () => {
|
||||
const endedNaturallyRef = useRef(false);
|
||||
// Track whether the current track has crossed the completion threshold.
|
||||
const crossedThresholdRef = useRef(false);
|
||||
// Track whether we've already recorded a completed play for the current track
|
||||
// (to avoid double-recording when both threshold crossed AND ended fires).
|
||||
const recordedCompletedRef = useRef(false);
|
||||
|
||||
// --- DOM -> store: media events -----------------------------------------
|
||||
useEffect(() => {
|
||||
@@ -73,16 +79,10 @@ export const AudioEngine = () => {
|
||||
if (store().isPlaying) store().pause();
|
||||
};
|
||||
const onEnded = () => {
|
||||
const trackId = loadedIdRef.current;
|
||||
if (trackId && !recordedCompletedRef.current) {
|
||||
endedNaturallyRef.current = true;
|
||||
recordedCompletedRef.current = true;
|
||||
try {
|
||||
void vibeService.feedback(trackId, 'completed').catch(() => {});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
// Just flag it — applyTrack (below) is the single place that sends
|
||||
// feedback, on the resulting track-change, so completion is recorded
|
||||
// exactly once per track.
|
||||
endedNaturallyRef.current = true;
|
||||
store().next();
|
||||
};
|
||||
|
||||
@@ -114,21 +114,23 @@ export const AudioEngine = () => {
|
||||
// If it crossed the threshold OR ended naturally, record as completed.
|
||||
const prevId = loadedIdRef.current;
|
||||
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
|
||||
if (prevId) {
|
||||
try {
|
||||
if (completed) {
|
||||
recordedCompletedRef.current = true;
|
||||
void vibeService.feedback(prevId, 'completed').catch(() => {});
|
||||
} else {
|
||||
void vibeService.feedback(prevId, 'skipped').catch(() => {});
|
||||
// Only vibe sessions want this feedback — plain library browsing
|
||||
// shouldn't write skip/completed evidence for tracks merely sampled.
|
||||
const inVibeSession = !!useVibeStore.getState().activeSessionId;
|
||||
if (prevId && inVibeSession) {
|
||||
if (suppressedFeedbackIds.delete(prevId)) {
|
||||
// Caller already recorded explicit feedback (e.g. dislike) for
|
||||
// this track — don't also record the implicit transition.
|
||||
} else {
|
||||
try {
|
||||
void vibeService.feedback(prevId, completed ? 'completed' : 'skipped').catch(() => {});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
endedNaturallyRef.current = false;
|
||||
crossedThresholdRef.current = false;
|
||||
recordedCompletedRef.current = false;
|
||||
loadedIdRef.current = id;
|
||||
|
||||
if (!id) {
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 INITIAL_BATCH_SIZE = 5;
|
||||
const PREFETCH_THRESHOLD = 3;
|
||||
@@ -19,7 +21,7 @@ function bestEffort(p: Promise<unknown>): void {
|
||||
}
|
||||
|
||||
export default function Vibe() {
|
||||
const { currentTrack, queue, setQueue, playTrack, next: playNext } = usePlaybackStore();
|
||||
const { currentTrack, queue, setQueue, playTrack, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
|
||||
const {
|
||||
activeSessionId,
|
||||
buffer,
|
||||
@@ -117,23 +119,34 @@ export default function Vibe() {
|
||||
if (idx > 0) {
|
||||
setBuffer(buffer.slice(idx));
|
||||
}
|
||||
}, [activeSessionId, currentTrack]);
|
||||
}, [activeSessionId, currentTrack, buffer, setBuffer]);
|
||||
|
||||
const handleKeep = useCallback(() => {
|
||||
if (currentTrack) bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
|
||||
if (currentTrack) {
|
||||
bestEffort(vibeService.feedback(currentTrack.id, 'promoted'));
|
||||
toast.success(`Kept "${currentTrack.title}"`);
|
||||
}
|
||||
}, [currentTrack]);
|
||||
|
||||
const handleDislike = useCallback(() => {
|
||||
if (currentTrack) bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
|
||||
if (currentTrack) {
|
||||
bestEffort(vibeService.feedback(currentTrack.id, 'disliked'));
|
||||
// AudioEngine would otherwise also record a 'skipped' on the track
|
||||
// change caused by playNext() below — suppress that duplicate.
|
||||
suppressAutoFeedback(currentTrack.id);
|
||||
}
|
||||
playNext();
|
||||
}, [currentTrack, playNext]);
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
// V2 plan expires via Redis TTL (2h). No explicit end endpoint.
|
||||
pause();
|
||||
setQueue([]);
|
||||
setCurrentTrack(null);
|
||||
reset();
|
||||
setEmpty(false);
|
||||
setError(null);
|
||||
}, [reset]);
|
||||
}, [reset, pause, setQueue, setCurrentTrack]);
|
||||
|
||||
const upcoming = currentTrack
|
||||
? (() => {
|
||||
@@ -208,12 +221,12 @@ export default function Vibe() {
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-muted">Or pick a seed track</h2>
|
||||
<ul className="max-h-72 space-y-1 overflow-y-auto">
|
||||
{libraryTracks.map((track) => (
|
||||
{libraryTracks.map((track, index) => (
|
||||
<li key={track.id}>
|
||||
<TrackRow
|
||||
track={track}
|
||||
queue={libraryTracks}
|
||||
index={libraryTracks.findIndex((t) => t.id === track.id)}
|
||||
index={index}
|
||||
showActions={false}
|
||||
/>
|
||||
</li>
|
||||
|
||||
@@ -12,6 +12,8 @@ interface PlaybackState {
|
||||
volume: number;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
/** Ids already played this shuffle "lap" (repeat-all), to avoid bouncing between the same few tracks. */
|
||||
shufflePlayed: Set<string>;
|
||||
|
||||
setQueue: (queue: Track[]) => void;
|
||||
playTrack: (track: Track) => void;
|
||||
@@ -36,8 +38,9 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
volume: 1,
|
||||
shuffle: false,
|
||||
repeat: 'none',
|
||||
shufflePlayed: new Set<string>(),
|
||||
|
||||
setQueue: (queue) => set({ queue }),
|
||||
setQueue: (queue) => set({ queue, shufflePlayed: new Set() }),
|
||||
|
||||
playTrack: (track) =>
|
||||
set({
|
||||
@@ -45,13 +48,14 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
isPlaying: true,
|
||||
position: 0,
|
||||
duration: track.duration ?? 0,
|
||||
shufflePlayed: new Set(),
|
||||
}),
|
||||
|
||||
play: () => set({ isPlaying: true }),
|
||||
pause: () => set({ isPlaying: false }),
|
||||
|
||||
next: () => {
|
||||
const { queue, currentTrack, shuffle, repeat } = get();
|
||||
const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get();
|
||||
if (queue.length === 0) return;
|
||||
|
||||
const idx = currentTrack ? queue.findIndex((t) => t.id === currentTrack.id) : -1;
|
||||
@@ -63,17 +67,22 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
}
|
||||
|
||||
if (shuffle) {
|
||||
// Shuffle: pick a random track from the remaining queue (excluding current)
|
||||
const remaining = queue.filter((t) => t.id !== currentTrack?.id);
|
||||
// Shuffle: pick a random track not yet played this lap (excludes current),
|
||||
// so repeat-all doesn't bounce between the same few tracks.
|
||||
const played = new Set(shufflePlayed);
|
||||
if (currentTrack) played.add(currentTrack.id);
|
||||
let remaining = queue.filter((t) => !played.has(t.id));
|
||||
if (remaining.length === 0) {
|
||||
if (repeat === 'all') {
|
||||
const pick = queue[Math.floor(Math.random() * queue.length)];
|
||||
set({ currentTrack: pick, position: 0, duration: pick.duration ?? 0, isPlaying: true });
|
||||
}
|
||||
return;
|
||||
if (repeat !== 'all') return;
|
||||
// Lap complete — start a fresh one.
|
||||
played.clear();
|
||||
if (currentTrack) played.add(currentTrack.id);
|
||||
remaining = queue.filter((t) => t.id !== currentTrack?.id);
|
||||
if (remaining.length === 0) return;
|
||||
}
|
||||
const pick = remaining[Math.floor(Math.random() * remaining.length)];
|
||||
set({ currentTrack: pick, position: 0, duration: pick.duration ?? 0, isPlaying: true });
|
||||
played.add(pick.id);
|
||||
set({ currentTrack: pick, shufflePlayed: played, position: 0, duration: pick.duration ?? 0, isPlaying: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user