initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import Fastify from 'fastify';
|
||||
import { Client as PgClient } 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 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';
|
||||
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 });
|
||||
|
||||
const pgClient = new PgClient({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
await pgClient.connect();
|
||||
|
||||
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(pgClient, 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();
|
||||
|
||||
// Health check route
|
||||
|
||||
fastify.get('/api/health', async (request, reply) => {
|
||||
const status = {
|
||||
postgres: 'unknown',
|
||||
redis: 'unknown',
|
||||
};
|
||||
|
||||
try {
|
||||
await pgClient.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(vibeRoutes, { prefix: '/api/vibe', 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 });
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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 pgClient.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, pgClient, redisClient };
|
||||
}
|
||||
Reference in New Issue
Block a user