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 vibeSessionsRoutes from './routes/vibe-sessions.routes.js'; import discoveryRoutes from './routes/discovery.routes.js'; import imagesRoutes from './routes/images.routes.js'; import { VibeSessionCoordinator } from './services/vibe-session-coordinator.service.js'; import { DiscoveryService } from './services/discovery.service.js'; // Single-listener deployment: the same placeholder the vibe-session routes use // when no x-user-id header is supplied. const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000'; export interface AppConfig { port: number; 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(() => {}); // Close the discovery loop without an operator in it. The worker's cron jobs // generate candidates; this timer is what decides which of them earn a // probation slot and enqueues the download. Evaluation is cheap SQL against a // capped batch, and every acquisition gate (relevance, novelty tolerance, // backlog, per-artist diversity) lives inside evalCandidates. const discoveryService = new DiscoveryService(dbService); const DISCOVERY_EVAL_INTERVAL_MS = 30 * 60 * 1000; const runDiscoveryEval = async () => { const results = await discoveryService.evalCandidates(DEFAULT_USER_ID); let enqueued = 0; for (const result of results) { if (!result.shouldAcquire) continue; try { await jobService.enqueueDiscoveryAcquisition(result.candidateId); enqueued++; } catch (err) { const reason = err instanceof Error ? err.message : 'failed to enqueue acquisition'; await discoveryService.markEnqueueFailed(result.candidateId, reason); } } if (results.length > 0) { console.log(`[Discovery] Evaluated ${results.length} candidates, enqueued ${enqueued}`); } }; const discoveryEvalTimer = setInterval(() => { runDiscoveryEval().catch((e) => console.error('[Discovery] eval failed:', e)); }, DISCOVERY_EVAL_INTERVAL_MS); // 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); const vibeSessionCoordinator = new VibeSessionCoordinator(dbService, sessionDirector); fastify.register(vibeSessionsRoutes, { prefix: '/api', coordinator: vibeSessionCoordinator, }); fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService }); // 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); clearInterval(discoveryEvalTimer); } 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 }; }