feat(discovery): acquire recommendations that keep their names
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>
This commit is contained in:
@@ -18,6 +18,11 @@ 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;
|
||||
@@ -91,6 +96,34 @@ export async function buildApp(config: AppConfig) {
|
||||
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();
|
||||
@@ -176,6 +209,7 @@ export async function buildApp(config: AppConfig) {
|
||||
clearInterval(fusionTimer);
|
||||
clearInterval(decayTimer);
|
||||
clearInterval(forgottenTimer);
|
||||
clearInterval(discoveryEvalTimer);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user