Files
muzick/backend/src/app.ts
T
kami c41316ee99 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>
2026-07-17 13:22:06 +04:00

196 lines
7.0 KiB
TypeScript

import Fastify from 'fastify';
import cors from '@fastify/cors';
import { Pool } from 'pg';
import { createClient as createRedisClient } from 'redis';
import { DbService } from './services/db.service.js';
import { JobService } from './services/job.service.js';
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 historyRoutes from './routes/history.routes.js';
import streamRoutes from './routes/stream.routes.js';
import quarantineRoutes from './routes/quarantine.routes.js';
import settingsRoutes from './routes/settings.routes.js';
import graphRoutes from './routes/graph.routes.js';
import { SessionDirector } from './services/session-director.service.js';
import v2Routes from './routes/v2.routes.js';
import discoveryRoutes from './routes/discovery.routes.js';
import imagesRoutes from './routes/images.routes.js';
export interface AppConfig {
port: number;
searchHost: string;
searchPort: number;
searchApiKey: string;
}
export async function buildApp(config: AppConfig) {
const fastify = Fastify({ logger: true });
await fastify.register(cors, {
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:5173', 'http://localhost:5174'],
});
// 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,
});
const redisClient = createRedisClient({
url: process.env.REDIS_URL,
});
await redisClient.connect();
const jobService = new JobService({ redisUrl: process.env.REDIS_URL! });
const searchService = new SearchService({
host: config.searchHost,
port: config.searchPort,
protocol: 'http',
apiKey: config.searchApiKey,
});
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
// docker-entrypoint mount only runs on first init, so older volumes miss them.
await dbService.ensureSchema();
await dbService.runMigrations();
// Keep the claim_fusion materialised view fresh. The trigger on
// `claims` fires NOTIFY on every change; rather than maintain a
// LISTEN consumer (separate long-lived connection), we refresh on a
// short interval. 10s staleness is well below any user-facing
// latency for a homelab music player.
const FUSION_REFRESH_MS = 10_000;
const fusionTimer = setInterval(() => {
dbService.refreshClaimFusion().catch(() => {});
}, FUSION_REFRESH_MS);
// Daily belief decay (spec §B.4). Runs hourly; the SQL only touches
// beliefs whose last_decayed_at is >1h old, so frequent runs are safe.
const DECAY_INTERVAL_MS = 60 * 60 * 1000;
const decayTimer = setInterval(() => {
dbService.decayBeliefs().catch((e) => console.error('[DB] belief decay failed:', e));
}, DECAY_INTERVAL_MS);
// Nightly 'forgotten' profile derivation (spec §B.2).
const FORGOTTEN_INTERVAL_MS = 24 * 60 * 60 * 1000;
const forgottenTimer = setInterval(() => {
dbService.deriveForgottenProfile().catch((e) =>
console.error('[DB] forgotten derivation failed:', e)
);
}, FORGOTTEN_INTERVAL_MS);
// Run both once at boot so the first session benefits.
dbService.decayBeliefs().catch(() => {});
dbService.deriveForgottenProfile().catch(() => {});
// Ensure the Typesense 'tracks' collection schema exists on boot so that
// the first search request doesn't hit a 404.
await searchService.ensureCollection();
// Auth: optional MUZICK_API_KEY / MUZICK_ADMIN_KEY
const apiKey = process.env.MUZICK_API_KEY;
const adminKey = process.env.MUZICK_ADMIN_KEY;
fastify.addHook('onRequest', async (request, reply) => {
if (!apiKey && !adminKey) return;
const path = request.url;
if (path === '/api/health') return;
const auth = request.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!token) return reply.code(401).send({ error: 'Unauthorized' });
// admin routes require admin key specifically
if (path.startsWith('/api/admin/') && adminKey) {
if (token !== adminKey) return reply.code(403).send({ error: 'Admin access denied' });
return;
}
// regular routes accept either key
if (token === apiKey || (adminKey && token === adminKey)) return;
reply.code(401).send({ error: 'Unauthorized' });
});
fastify.get('/api/health', async (request, reply) => {
const status = {
postgres: 'unknown',
redis: 'unknown',
};
try {
await pgPool.query('SELECT 1');
status.postgres = 'ok';
} catch (err) {
status.postgres = 'error';
fastify.log.error(err);
}
try {
const redisRes = await redisClient.ping();
if (redisRes === 'PONG') {
status.redis = 'ok';
}
} catch (err) {
status.redis = 'error';
fastify.log.error(err);
}
const isHealthy = status.postgres === 'ok' && status.redis === 'ok';
if (isHealthy) {
return reply.code(200).send(status);
} else {
return reply.code(503).send(status);
}
});
fastify.register(imagesRoutes, { prefix: '/api' });
fastify.register(libraryRoutes, { prefix: '/api', dbService });
fastify.register(searchRoutes, { prefix: '/api', dbService });
fastify.register(adminRoutes, { prefix: '/api/admin', jobService, dbService });
fastify.register(historyRoutes, { prefix: '/api', dbService });
fastify.register(streamRoutes, { prefix: '/api', dbService });
fastify.register(quarantineRoutes, { prefix: '/api', dbService });
fastify.register(settingsRoutes, { prefix: '/api', dbService });
fastify.register(graphRoutes, { prefix: '/api', dbService });
const sessionDirector = new SessionDirector(dbService);
fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector });
fastify.register(discoveryRoutes, { prefix: '/api', dbService });
// 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 () => {
try {
clearInterval(fusionTimer);
clearInterval(decayTimer);
clearInterval(forgottenTimer);
} catch (err) {
fastify.log.error(err);
}
try {
await pgPool.end();
} catch (err) {
fastify.log.error(err);
}
try {
await redisClient.quit();
} catch (err) {
fastify.log.error(err);
}
try {
await jobService.close();
} catch (err) {
fastify.log.error(err);
}
});
return { fastify, pgPool, redisClient };
}