bfe22745bc
Acquisition ran yt-dlp without --embed-metadata, so every download arrived untagged. The scanner then stored the video id as the title and "Unknown Artist" as the artist, the vetted-candidate tag check rejected the mismatch, and all 18 acquired tracks were hidden and retired. - Pass --embed-metadata so downloads carry real tags. - Let a scan take fallback title/artist from the candidate, for sources that still ship untagged files. - Install Deno alongside yt-dlp: YouTube guards some formats with a JS challenge yt-dlp must execute, and no other runtime is enabled. - Dedupe candidates by artist and title. The (source, external_id) key misses the same song reaching us under two Deezer release ids. Also carries the in-flight discovery work this builds on: the Recommendations page replacing Discover, the discovery source service, and the acquisition spec tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
8.9 KiB
TypeScript
235 lines
8.9 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 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 };
|
|
}
|