feat: enhance discovery, vibe sessions, and library enrichment
This commit is contained in:
+6
-1
@@ -1,5 +1,10 @@
|
||||
FROM node:20-slim
|
||||
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
# yt-dlp is intentionally opt-in. Enabling its image build alone does not make
|
||||
# acquisition live: the worker additionally requires explicit runtime gates.
|
||||
ARG INSTALL_YTDLP=false
|
||||
RUN apt-get update && apt-get install -y ffmpeg \
|
||||
&& if [ "$INSTALL_YTDLP" = "true" ]; then apt-get install -y yt-dlp; fi \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { access, mkdir, realpath, stat } from 'node:fs/promises';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import type { Pool } from 'pg';
|
||||
import { ScannerService } from './scanner.service.js';
|
||||
|
||||
type CandidateRow = {
|
||||
id: string;
|
||||
source: string;
|
||||
notes: unknown;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type AcquisitionSpec = {
|
||||
url: string;
|
||||
expectedTitle?: string;
|
||||
expectedArtist?: string;
|
||||
};
|
||||
|
||||
export type AcquisitionResult =
|
||||
| { status: 'acquired'; trackId: string }
|
||||
| { status: 'disabled'; reason: string }
|
||||
| { status: 'failed'; reason: string };
|
||||
|
||||
export interface AcquisitionConfig {
|
||||
enabled: boolean;
|
||||
ytDlpPath: string;
|
||||
musicDir: string;
|
||||
destinationDir: string;
|
||||
allowedHosts: Set<string>;
|
||||
timeoutMs: number;
|
||||
maxFileBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* System E is intentionally off unless every gate is configured. In
|
||||
* particular, a bare `yt-dlp` command is not accepted: an absolute executable
|
||||
* path avoids PATH surprises in a long-running, network-enabled worker.
|
||||
*/
|
||||
export function acquisitionConfigFromEnv(env = process.env): AcquisitionConfig {
|
||||
const musicDir = path.resolve(env.MUSIC_DIR || '/music');
|
||||
const requestedDestination = env.MUZICK_ACQUISITION_DIR || '.recommendations';
|
||||
const destinationDir = path.resolve(musicDir, requestedDestination);
|
||||
return {
|
||||
enabled: env.MUZICK_ACQUISITION_ENABLED === 'true',
|
||||
ytDlpPath: env.MUZICK_ACQUISITION_YTDLP_PATH || '',
|
||||
musicDir,
|
||||
destinationDir,
|
||||
allowedHosts: new Set(
|
||||
(env.MUZICK_ACQUISITION_ALLOWED_HOSTS || '')
|
||||
.split(',').map((value) => value.trim().toLowerCase()).filter(Boolean)
|
||||
),
|
||||
timeoutMs: Math.max(10_000, Math.min(Number(env.MUZICK_ACQUISITION_TIMEOUT_MS || 120_000), 15 * 60_000)),
|
||||
maxFileBytes: Math.max(1_000_000, Math.min(Number(env.MUZICK_ACQUISITION_MAX_FILE_BYTES || 250 * 1024 * 1024), 2 * 1024 * 1024 * 1024)),
|
||||
};
|
||||
}
|
||||
|
||||
function isWithin(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function normalizedMetadata(value: string): string {
|
||||
return value.toLowerCase().normalize('NFKD').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
||||
}
|
||||
|
||||
function matchesExpected(actual: string, expected: string | undefined): boolean {
|
||||
if (!expected) return true;
|
||||
const left = normalizedMetadata(actual);
|
||||
const right = normalizedMetadata(expected);
|
||||
return left === right || left.includes(right) || right.includes(left);
|
||||
}
|
||||
|
||||
/** Parse only a deliberately supplied HTTPS source URL; never accept argv/query strings. */
|
||||
export function parseAcquisitionSpec(notes: unknown, allowedHosts: Set<string>): AcquisitionSpec {
|
||||
const notesValue = typeof notes === 'string' ? JSON.parse(notes) : notes;
|
||||
const candidate = (notesValue as { acquisition?: unknown } | null)?.acquisition;
|
||||
if (!candidate || typeof candidate !== 'object') {
|
||||
throw new Error('candidate has no resolved acquisition source');
|
||||
}
|
||||
const { url, expectedTitle, expectedArtist } = candidate as Record<string, unknown>;
|
||||
if (typeof url !== 'string') throw new Error('acquisition source URL is required');
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error('acquisition source URL is invalid');
|
||||
}
|
||||
if (parsed.protocol !== 'https:') throw new Error('acquisition source URL must use HTTPS');
|
||||
if (parsed.username || parsed.password) throw new Error('acquisition source URL must not contain credentials');
|
||||
if (!allowedHosts.has(parsed.hostname.toLowerCase())) {
|
||||
throw new Error(`acquisition host is not allow-listed: ${parsed.hostname}`);
|
||||
}
|
||||
return {
|
||||
url: parsed.toString(),
|
||||
expectedTitle: typeof expectedTitle === 'string' ? expectedTitle.slice(0, 500) : undefined,
|
||||
expectedArtist: typeof expectedArtist === 'string' ? expectedArtist.slice(0, 500) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function runDownloader(executable: string, args: string[], timeoutMs: number): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
child.once('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.once('close', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) return resolve(stdout);
|
||||
const detail = (stderr || `exit=${code ?? 'null'} signal=${signal ?? 'none'}`).trim().slice(0, 1000);
|
||||
reject(new Error(`downloader failed: ${detail}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export class AcquisitionService {
|
||||
private readonly config: AcquisitionConfig;
|
||||
|
||||
constructor(
|
||||
private readonly pgPool: Pool,
|
||||
private readonly scanner: ScannerService,
|
||||
config = acquisitionConfigFromEnv(),
|
||||
) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async disabledReason(): Promise<string | null> {
|
||||
if (!this.config.enabled) return 'MUZICK_ACQUISITION_ENABLED is not true';
|
||||
if (!path.isAbsolute(this.config.ytDlpPath)) return 'MUZICK_ACQUISITION_YTDLP_PATH must be an absolute executable path';
|
||||
if (this.config.allowedHosts.size === 0) return 'MUZICK_ACQUISITION_ALLOWED_HOSTS is empty';
|
||||
if (!isWithin(this.config.musicDir, this.config.destinationDir)) return 'MUZICK_ACQUISITION_DIR must stay inside MUSIC_DIR';
|
||||
try {
|
||||
await access(this.config.ytDlpPath, fsConstants.X_OK);
|
||||
} catch {
|
||||
return `downloader is not executable: ${this.config.ytDlpPath}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async setStatus(candidateId: string, status: string, error: string | null): Promise<void> {
|
||||
await this.pgPool.query(
|
||||
`UPDATE discovery_candidates
|
||||
SET status = $2, last_eval_at = NOW(), last_error = $3
|
||||
WHERE id = $1`,
|
||||
[candidateId, status, error?.slice(0, 2000) ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
async acquire(candidateId: string): Promise<AcquisitionResult> {
|
||||
const rowResult = await this.pgPool.query<CandidateRow>(
|
||||
`SELECT id, source, notes, status FROM discovery_candidates WHERE id = $1`, [candidateId]
|
||||
);
|
||||
const candidate = rowResult.rows[0];
|
||||
if (!candidate) return { status: 'failed', reason: 'candidate does not exist' };
|
||||
if (candidate.status !== 'acquiring') {
|
||||
return { status: 'failed', reason: `candidate is not acquiring (status=${candidate.status})` };
|
||||
}
|
||||
|
||||
const disabled = await this.disabledReason();
|
||||
if (disabled) {
|
||||
await this.setStatus(candidate.id, 'acquisition_disabled', disabled);
|
||||
return { status: 'disabled', reason: disabled };
|
||||
}
|
||||
|
||||
let spec: AcquisitionSpec;
|
||||
try {
|
||||
spec = parseAcquisitionSpec(candidate.notes, this.config.allowedHosts);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : 'invalid acquisition source';
|
||||
await this.setStatus(candidate.id, 'awaiting_resolution', reason);
|
||||
return { status: 'failed', reason };
|
||||
}
|
||||
|
||||
const sourceTrust = await this.pgPool.query<{ key: string }>(
|
||||
'SELECT key FROM source_trust WHERE key = $1', [candidate.source]
|
||||
);
|
||||
if (sourceTrust.rows.length === 0) {
|
||||
const reason = `candidate source is not registered in source_trust: ${candidate.source}`;
|
||||
await this.setStatus(candidate.id, 'failed', reason);
|
||||
return { status: 'failed', reason };
|
||||
}
|
||||
|
||||
const candidateDir = path.join(this.config.destinationDir, candidate.id);
|
||||
if (!isWithin(this.config.destinationDir, candidateDir)) {
|
||||
const reason = 'candidate destination escaped acquisition directory';
|
||||
await this.setStatus(candidate.id, 'failed', reason);
|
||||
return { status: 'failed', reason };
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir(candidateDir, { recursive: true });
|
||||
const resolvedDestination = await realpath(this.config.destinationDir);
|
||||
const resolvedCandidateDir = await realpath(candidateDir);
|
||||
if (!isWithin(resolvedDestination, resolvedCandidateDir)) {
|
||||
throw new Error('resolved candidate destination escaped acquisition directory');
|
||||
}
|
||||
|
||||
await this.pgPool.query(
|
||||
`UPDATE discovery_candidates
|
||||
SET status = 'downloading', acquisition_attempts = acquisition_attempts + 1,
|
||||
last_eval_at = NOW(), last_error = NULL
|
||||
WHERE id = $1`, [candidate.id]
|
||||
);
|
||||
|
||||
// Arguments are fixed by us. The sole untrusted value is the validated URL
|
||||
// and spawn() is invoked with shell:false, so no command interpolation is
|
||||
// possible. One URL / one item is deliberate: playlists are out of scope.
|
||||
const outputTemplate = path.join(candidateDir, '%(id)s.%(ext)s');
|
||||
const stdout = await runDownloader(this.config.ytDlpPath, [
|
||||
'--no-playlist', '--no-progress', '--restrict-filenames',
|
||||
'--extract-audio', '--audio-format', 'mp3', '--audio-quality', '5',
|
||||
'--output', outputTemplate,
|
||||
'--print', 'after_move:filepath',
|
||||
'--', spec.url,
|
||||
], this.config.timeoutMs);
|
||||
const reportedPaths = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (reportedPaths.length !== 1) throw new Error('downloader did not report exactly one output file');
|
||||
const outputPath = path.resolve(reportedPaths[0]);
|
||||
if (!isWithin(resolvedCandidateDir, outputPath) || path.extname(outputPath).toLowerCase() !== '.mp3') {
|
||||
throw new Error('downloader reported an unsafe or unsupported output path');
|
||||
}
|
||||
await access(outputPath, fsConstants.R_OK);
|
||||
const outputStat = await stat(outputPath);
|
||||
if (!outputStat.isFile() || outputStat.size <= 0 || outputStat.size > this.config.maxFileBytes) {
|
||||
throw new Error(`downloaded file violates size limit (${this.config.maxFileBytes} bytes)`);
|
||||
}
|
||||
|
||||
await this.setStatus(candidate.id, 'scanning', null);
|
||||
const scan = await this.scanner.scanDirectory(candidateDir, {
|
||||
sourceType: 'RECOMMENDATION', probationStatus: 'probation', candidateId: candidate.id,
|
||||
});
|
||||
if (scan.trackIds.length !== 1) {
|
||||
throw new Error(`scanner created ${scan.trackIds.length} tracks; expected exactly one`);
|
||||
}
|
||||
const trackId = scan.trackIds[0];
|
||||
const metadata = await this.pgPool.query<{ title: string; artist: string }>(
|
||||
'SELECT title, artist FROM tracks WHERE id = $1', [trackId]
|
||||
);
|
||||
const scanned = metadata.rows[0];
|
||||
if (!scanned || !matchesExpected(scanned.title, spec.expectedTitle) || !matchesExpected(scanned.artist, spec.expectedArtist)) {
|
||||
await this.pgPool.query(
|
||||
`UPDATE tracks SET state = 'HIDDEN', probation_status = 'retired'
|
||||
WHERE id = $1`, [trackId]
|
||||
);
|
||||
throw new Error('downloaded metadata does not match the vetted candidate');
|
||||
}
|
||||
|
||||
const client = await this.pgPool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`UPDATE discovery_candidates
|
||||
SET status = 'acquired', acquired_track_id = $2, acquired_at = NOW(),
|
||||
last_eval_at = NOW(), last_error = NULL
|
||||
WHERE id = $1`, [candidate.id, trackId]
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO claims (
|
||||
subject_type, subject_id, predicate, object_type, object_id,
|
||||
source, confidence, raw
|
||||
) VALUES ('track', $1::uuid, 'acquired_from', 'discovery_candidate', $2::uuid,
|
||||
$3, 1.0, $4::jsonb)
|
||||
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
|
||||
DO UPDATE SET confidence = EXCLUDED.confidence,
|
||||
last_reinforced_at = NOW(), raw = EXCLUDED.raw`,
|
||||
[trackId, candidate.id, candidate.source, JSON.stringify({ expectedTitle: spec.expectedTitle, expectedArtist: spec.expectedArtist })]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
return { status: 'acquired', trackId };
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : 'acquisition failed';
|
||||
await this.setStatus(candidate.id, 'failed', reason);
|
||||
return { status: 'failed', reason };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
AUDIO_ANALYSIS_JOB_OPTIONS,
|
||||
AUDIO_ANALYSIS_VERSION,
|
||||
audioAnalysisJobId,
|
||||
normaliseDanceability,
|
||||
normaliseMeanSquareEnergy,
|
||||
validBpm,
|
||||
validKey,
|
||||
} from './audio-analysis.js';
|
||||
|
||||
test('maps mean-square energy through a dBFS range instead of saturating mastered tracks', () => {
|
||||
assert.equal(normaliseMeanSquareEnergy(-1), null);
|
||||
assert.equal(normaliseMeanSquareEnergy(0), 0);
|
||||
assert.ok(Math.abs((normaliseMeanSquareEnergy(0.0001) ?? 0) - 8 / 42) < 0.000001); // -40 dBFS
|
||||
assert.ok(Math.abs((normaliseMeanSquareEnergy(0.01) ?? 0) - 28 / 42) < 0.000001); // -20 dBFS
|
||||
assert.equal(normaliseMeanSquareEnergy(0.26), 1); // above the -6 dBFS ceiling
|
||||
});
|
||||
|
||||
test('only accepts values that are safe for Vibe consumers and database constraints', () => {
|
||||
assert.equal(validBpm(29.9), null);
|
||||
assert.equal(validBpm(128.04), 128);
|
||||
assert.equal(validBpm(300.1), null);
|
||||
assert.equal(validKey(' 8A '), '8A');
|
||||
assert.equal(validKey(' '.repeat(33)), null);
|
||||
assert.equal(normaliseDanceability(-0.1), null);
|
||||
assert.equal(normaliseDanceability(1.5), 0.5);
|
||||
assert.equal(normaliseDanceability(9), 1);
|
||||
});
|
||||
|
||||
test('uses a versioned, retryable, deduplicated job contract', () => {
|
||||
assert.equal(AUDIO_ANALYSIS_VERSION, 2);
|
||||
assert.equal(audioAnalysisJobId('track-1'), 'audio-track-1');
|
||||
assert.equal(AUDIO_ANALYSIS_JOB_OPTIONS.attempts, 3);
|
||||
assert.deepEqual(AUDIO_ANALYSIS_JOB_OPTIONS.backoff, { type: 'exponential', delay: 5_000 });
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Shared audio-analysis contract.
|
||||
*
|
||||
* Values in track_audio_features are deliberately modest: they are inputs to
|
||||
* recommendation heuristics, not a claim that we can infer every Spotify-like
|
||||
* descriptor from local DSP. Keep the persisted set restricted to fields we
|
||||
* actually compute and validate before it reaches the database.
|
||||
*/
|
||||
export const AUDIO_ANALYSIS_VERSION = 2;
|
||||
|
||||
export const AUDIO_ANALYSIS_FEATURES = ['bpm', 'key', 'energy', 'danceability'] as const;
|
||||
export type AudioAnalysisFeature = typeof AUDIO_ANALYSIS_FEATURES[number];
|
||||
|
||||
export const AUDIO_ANALYSIS_JOB_OPTIONS = {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5_000 },
|
||||
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 10_000 },
|
||||
removeOnFail: { age: 30 * 24 * 60 * 60, count: 10_000 },
|
||||
} as const;
|
||||
|
||||
const ENERGY_FLOOR_DBFS = -48;
|
||||
const ENERGY_CEILING_DBFS = -6;
|
||||
|
||||
export function clampUnit(value: number): number | null {
|
||||
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Essentia's mean-square sample energy to a perceptually useful 0..1
|
||||
* RMS level. A linear multiplier makes normal mastered music saturate at 1;
|
||||
* dBFS preserves the distinction between quiet and loud recordings.
|
||||
*/
|
||||
export function normaliseMeanSquareEnergy(meanSquare: number): number | null {
|
||||
if (!Number.isFinite(meanSquare) || meanSquare < 0) return null;
|
||||
if (meanSquare === 0) return 0;
|
||||
|
||||
const rms = Math.sqrt(meanSquare);
|
||||
const dbfs = 20 * Math.log10(rms);
|
||||
return clampUnit((dbfs - ENERGY_FLOOR_DBFS) / (ENERGY_CEILING_DBFS - ENERGY_FLOOR_DBFS));
|
||||
}
|
||||
|
||||
export function normaliseDanceability(value: number): number | null {
|
||||
if (!Number.isFinite(value) || value < 0) return null;
|
||||
// Essentia Danceability is normally 0..3. Values outside that range are
|
||||
// clamped rather than allowed to poison downstream scoring.
|
||||
return clampUnit(value / 3);
|
||||
}
|
||||
|
||||
export function validBpm(value: number | null | undefined): number | null {
|
||||
if (!Number.isFinite(value) || value == null || value < 30 || value > 300) return null;
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function validKey(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const key = value.trim();
|
||||
// Keep tag conventions such as "8A" intact, but reject malformed or
|
||||
// unexpectedly large values before persisting them.
|
||||
return key.length > 0 && key.length <= 32 ? key : null;
|
||||
}
|
||||
|
||||
export function audioAnalysisJobId(trackId: string): string {
|
||||
return `audio-${trackId}`;
|
||||
}
|
||||
@@ -16,6 +16,13 @@
|
||||
import { spawn } from 'child_process';
|
||||
import mm from 'music-metadata';
|
||||
import type { Queryable } from './db.js';
|
||||
import {
|
||||
AUDIO_ANALYSIS_VERSION,
|
||||
normaliseDanceability,
|
||||
normaliseMeanSquareEnergy,
|
||||
validBpm,
|
||||
validKey,
|
||||
} from './audio-analysis.js';
|
||||
|
||||
// Lazy WASM singleton — heavy to load (~2.4 MB), so we initialise once and
|
||||
// reuse across all enrichment jobs within the same worker process.
|
||||
@@ -39,13 +46,24 @@ async function getEssentia() {
|
||||
return essentiaReady;
|
||||
}
|
||||
|
||||
/** Decode any audio file to a mono Float32Array at 44100 Hz via ffmpeg. */
|
||||
const MAX_DECODE_SECONDS = Math.max(30, Math.min(Number(process.env.MUZICK_AUDIO_MAX_SECONDS || 20 * 60), 2 * 60 * 60));
|
||||
const MAX_PCM_BYTES = Math.max(4 * 1024 * 1024, Math.min(Number(process.env.MUZICK_AUDIO_MAX_PCM_BYTES || 96 * 1024 * 1024), 256 * 1024 * 1024));
|
||||
|
||||
/** Decode a bounded audio prefix to mono Float32Array at 44100 Hz via ffmpeg. */
|
||||
function decodeAudioToFloat32(filePath: string): Promise<Float32Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
let settled = false;
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
const ff = spawn('ffmpeg', [
|
||||
'-i', filePath,
|
||||
'-vn',
|
||||
'-t', String(MAX_DECODE_SECONDS),
|
||||
'-acodec', 'pcm_f32le',
|
||||
'-ar', '44100',
|
||||
'-ac', '1',
|
||||
@@ -53,16 +71,22 @@ function decodeAudioToFloat32(filePath: string): Promise<Float32Array> {
|
||||
'pipe:1',
|
||||
], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
|
||||
ff.stdout.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
ff.stdout.on('end', () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
resolve(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
|
||||
});
|
||||
ff.on('error', reject);
|
||||
ff.on('close', (code) => {
|
||||
if (code !== 0 && chunks.length === 0) {
|
||||
reject(new Error(`ffmpeg exited with code ${code} for ${filePath}`));
|
||||
ff.stdout.on('data', (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_PCM_BYTES) {
|
||||
ff.kill('SIGTERM');
|
||||
fail(new Error(`decoded audio exceeds ${MAX_PCM_BYTES} byte safety limit`));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
ff.on('error', (error) => fail(error));
|
||||
ff.on('close', (code) => {
|
||||
if (settled) return;
|
||||
if (code !== 0) return fail(new Error(`ffmpeg exited with code ${code} for ${filePath}`));
|
||||
const buf = Buffer.concat(chunks);
|
||||
settled = true;
|
||||
resolve(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -72,7 +96,6 @@ export interface AudioFeatures {
|
||||
key: string | null;
|
||||
energy: number | null;
|
||||
danceability: number | null;
|
||||
dynamicComplexity: number | null;
|
||||
}
|
||||
|
||||
export class AudioFeaturesService {
|
||||
@@ -91,9 +114,21 @@ export class AudioFeaturesService {
|
||||
instrumentalness REAL,
|
||||
liveness REAL,
|
||||
valence_score REAL,
|
||||
tempo REAL
|
||||
tempo REAL,
|
||||
analysis_version SMALLINT NOT NULL DEFAULT 0,
|
||||
source_hash TEXT,
|
||||
analyzed_at TIMESTAMPTZ
|
||||
)`
|
||||
);
|
||||
// CREATE TABLE IF NOT EXISTS does not evolve an existing library. Keep the
|
||||
// worker safe when it starts before the backend has had a chance to apply
|
||||
// the corresponding migration.
|
||||
await this.pgClient.query(
|
||||
`ALTER TABLE track_audio_features
|
||||
ADD COLUMN IF NOT EXISTS analysis_version SMALLINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS source_hash TEXT,
|
||||
ADD COLUMN IF NOT EXISTS analyzed_at TIMESTAMPTZ`
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pass 1: embedded tags ──────────────────────────────────────────────────
|
||||
@@ -101,8 +136,8 @@ export class AudioFeaturesService {
|
||||
private async readEmbeddedTags(filePath: string): Promise<{ bpm: number | null; key: string | null; replayGainDb: number | null }> {
|
||||
try {
|
||||
const { common } = await mm.parseFile(filePath, { duration: false });
|
||||
const bpm = common.bpm && Number.isFinite(common.bpm) && common.bpm > 0 ? common.bpm : null;
|
||||
const key = common.key?.trim() || null;
|
||||
const bpm = validBpm(common.bpm);
|
||||
const key = validKey(common.key);
|
||||
const rgRaw = (common as any).replaygain_track_gain;
|
||||
let replayGainDb: number | null = null;
|
||||
if (rgRaw != null) {
|
||||
@@ -118,7 +153,7 @@ export class AudioFeaturesService {
|
||||
// ── Pass 2: essentia.js DSP ────────────────────────────────────────────────
|
||||
|
||||
private async analyseWithEssentia(filePath: string, needBpm: boolean, needKey: boolean): Promise<AudioFeatures> {
|
||||
const result: AudioFeatures = { bpm: null, key: null, energy: null, danceability: null, dynamicComplexity: null };
|
||||
const result: AudioFeatures = { bpm: null, key: null, energy: null, danceability: null };
|
||||
|
||||
const { essentia } = await getEssentia();
|
||||
const signal = await decodeAudioToFloat32(filePath);
|
||||
@@ -129,7 +164,7 @@ export class AudioFeaturesService {
|
||||
if (needBpm) {
|
||||
try {
|
||||
const rhythm = essentia.RhythmExtractor2013(vec);
|
||||
if (rhythm.bpm > 0) result.bpm = Math.round(rhythm.bpm * 10) / 10;
|
||||
result.bpm = validBpm(rhythm.bpm);
|
||||
} catch (err) {
|
||||
console.warn('[AudioFeatures] RhythmExtractor2013 failed:', (err as Error).message);
|
||||
}
|
||||
@@ -138,7 +173,7 @@ export class AudioFeaturesService {
|
||||
if (needKey) {
|
||||
try {
|
||||
const keyResult = essentia.KeyExtractor(vec);
|
||||
if (keyResult.key) result.key = `${keyResult.key} ${keyResult.scale}`;
|
||||
if (keyResult.key) result.key = validKey(`${keyResult.key} ${keyResult.scale}`);
|
||||
} catch (err) {
|
||||
console.warn('[AudioFeatures] KeyExtractor failed:', (err as Error).message);
|
||||
}
|
||||
@@ -147,31 +182,21 @@ export class AudioFeaturesService {
|
||||
// Energy and danceability are only available from signal analysis — always compute.
|
||||
try {
|
||||
const energy = essentia.Energy(vec);
|
||||
// Essentia Energy returns the sum of squared samples, normalised to [0,1]
|
||||
// by dividing by signal length. Map to a sensible 0-1 display value.
|
||||
const rawEnergy = energy.energy / signal.length;
|
||||
result.energy = Math.min(1, rawEnergy * 1000); // typical values << 0.001
|
||||
// Energy is the sum of squared samples. Convert it to a mean-square
|
||||
// level, then use the shared dBFS normalization rather than an arbitrary
|
||||
// linear multiplier that saturates mastered music near 1.0.
|
||||
result.energy = normaliseMeanSquareEnergy(energy.energy / signal.length);
|
||||
} catch (err) {
|
||||
console.warn('[AudioFeatures] Energy failed:', (err as Error).message);
|
||||
}
|
||||
|
||||
try {
|
||||
const dance = essentia.Danceability(vec);
|
||||
// Danceability output range is 0-3; normalise to 0-1.
|
||||
result.danceability = Math.min(1, dance.danceability / 3);
|
||||
result.danceability = normaliseDanceability(dance.danceability);
|
||||
} catch (err) {
|
||||
console.warn('[AudioFeatures] Danceability failed:', (err as Error).message);
|
||||
}
|
||||
|
||||
try {
|
||||
const dynComp = essentia.DynamicComplexity(vec);
|
||||
// DynamicComplexity is a measure of loudness variation (0 = flat/compressed,
|
||||
// higher = dynamic). Scale to 0-1 (typical max ~10 dB).
|
||||
result.dynamicComplexity = Math.min(1, dynComp.dynamicComplexity / 10);
|
||||
} catch (err) {
|
||||
console.warn('[AudioFeatures] DynamicComplexity failed:', (err as Error).message);
|
||||
}
|
||||
|
||||
vec.delete(); // free WASM memory
|
||||
return result;
|
||||
}
|
||||
@@ -179,8 +204,8 @@ export class AudioFeaturesService {
|
||||
// ── Public entry point ─────────────────────────────────────────────────────
|
||||
|
||||
async extractAndPersist(trackId: string): Promise<void> {
|
||||
const pathRes = await this.pgClient.query<{ path: string }>(
|
||||
'SELECT path FROM tracks WHERE id = $1',
|
||||
const pathRes = await this.pgClient.query<{ path: string; hash: string }>(
|
||||
'SELECT path, hash FROM tracks WHERE id = $1',
|
||||
[trackId]
|
||||
);
|
||||
const row = pathRes.rows[0];
|
||||
@@ -210,15 +235,19 @@ export class AudioFeaturesService {
|
||||
const tempo = bpm;
|
||||
|
||||
await this.pgClient.query(
|
||||
`INSERT INTO track_audio_features (track_id, bpm, key, energy, danceability, tempo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`INSERT INTO track_audio_features
|
||||
(track_id, bpm, key, energy, danceability, tempo, analysis_version, source_hash, analyzed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
||||
ON CONFLICT (track_id) DO UPDATE SET
|
||||
bpm = COALESCE(EXCLUDED.bpm, track_audio_features.bpm),
|
||||
key = COALESCE(EXCLUDED.key, track_audio_features.key),
|
||||
energy = COALESCE(EXCLUDED.energy, track_audio_features.energy),
|
||||
danceability = COALESCE(EXCLUDED.danceability, track_audio_features.danceability),
|
||||
tempo = COALESCE(EXCLUDED.tempo, track_audio_features.tempo)`,
|
||||
[trackId, bpm, key, energy, danceability, tempo]
|
||||
bpm = EXCLUDED.bpm,
|
||||
key = EXCLUDED.key,
|
||||
energy = EXCLUDED.energy,
|
||||
danceability = EXCLUDED.danceability,
|
||||
tempo = EXCLUDED.tempo,
|
||||
analysis_version = EXCLUDED.analysis_version,
|
||||
source_hash = EXCLUDED.source_hash,
|
||||
analyzed_at = EXCLUDED.analyzed_at`,
|
||||
[trackId, bpm, key, energy, danceability, tempo, AUDIO_ANALYSIS_VERSION, row.hash]
|
||||
);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
DeezerClient,
|
||||
upscaleITunesArtwork,
|
||||
} from './integrations/index.js';
|
||||
import { AudioFeaturesService } from './audio-features.service.js';
|
||||
import { MbSpineWriter } from './mb-spine-writer.js';
|
||||
import {
|
||||
normalizeForMatching,
|
||||
@@ -43,6 +42,13 @@ interface TrackRow {
|
||||
artist_id: string | null;
|
||||
}
|
||||
|
||||
/** Stored as BullMQ's job return value so a completed job never masquerades
|
||||
* as an enrichment hit when a toggle was off or every provider had no match. */
|
||||
export interface EnrichmentJobOutcome {
|
||||
outcome: 'updated' | 'unchanged' | 'skipped' | 'not_found' | 'no_result';
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the external-integration clients into real metadata enrichment.
|
||||
*
|
||||
@@ -63,11 +69,7 @@ export class EnrichmentService {
|
||||
private readonly theaudiodb = new TheAudioDbClient();
|
||||
private readonly itunes = new ITunesClient();
|
||||
private readonly deezer = new DeezerClient();
|
||||
private readonly audioFeatures: AudioFeaturesService;
|
||||
|
||||
constructor(private pgClient: Queryable) {
|
||||
this.audioFeatures = new AudioFeaturesService(pgClient);
|
||||
}
|
||||
constructor(private pgClient: Queryable) {}
|
||||
|
||||
/**
|
||||
* Self-provision the enrichment-specific schema additions. Idempotent; mirrors
|
||||
@@ -540,15 +542,22 @@ export class EnrichmentService {
|
||||
* provider that returns nothing or throws is logged and skipped without
|
||||
* affecting the others. Safe to re-run (stable, no duplicate rows).
|
||||
*/
|
||||
async enrichTrack(trackId: string): Promise<void> {
|
||||
async enrichTrack(trackId: string): Promise<EnrichmentJobOutcome> {
|
||||
const track = await this.loadTrack(trackId);
|
||||
if (!track) {
|
||||
console.warn(`[Enrich] track not found: ${trackId}`);
|
||||
return;
|
||||
return { outcome: 'not_found', detail: 'Track no longer exists.' };
|
||||
}
|
||||
|
||||
// Load enrichment settings. Default all to true (best-effort).
|
||||
const settings = await this.loadSettings();
|
||||
const trackEnrichmentEnabled = settings.enrich_metadata
|
||||
|| settings.enrich_genres
|
||||
|| settings.enrich_lyrics;
|
||||
if (!trackEnrichmentEnabled) {
|
||||
console.log(`[Enrich] track ${trackId}: skipped (all track enrichment toggles disabled)`);
|
||||
return { outcome: 'skipped', detail: 'All track enrichment toggles are disabled.' };
|
||||
}
|
||||
|
||||
const summary: string[] = [];
|
||||
const album = track.album_title ?? undefined;
|
||||
@@ -810,22 +819,14 @@ export class EnrichmentService {
|
||||
// NOTE: Album cover art is fetched by the separate `album_cover` job (Discogs
|
||||
// + Cover Art Archive), not inline here. See refreshAlbumCover().
|
||||
|
||||
// --- g. Audio features from embedded tags --------------------------------
|
||||
if (settings.enrich_audio_analysis) {
|
||||
try {
|
||||
await this.audioFeatures.ensureSchema();
|
||||
await this.audioFeatures.extractAndPersist(trackId);
|
||||
summary.push('audio_features');
|
||||
} catch (err) {
|
||||
console.warn('[Enrich] Audio features step failed:', (err as Error).message);
|
||||
}
|
||||
} // enrich_audio_analysis
|
||||
|
||||
console.log(
|
||||
`[Enrich] track ${trackId} enriched: ${
|
||||
summary.length > 0 ? summary.join(', ') : 'nothing'
|
||||
}`
|
||||
);
|
||||
return summary.length > 0
|
||||
? { outcome: 'updated', detail: summary.join(', ') }
|
||||
: { outcome: 'no_result', detail: 'No provider returned usable metadata.' };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -838,6 +839,7 @@ export class EnrichmentService {
|
||||
*/
|
||||
private async loadSettings(): Promise<{
|
||||
enrich_metadata: boolean;
|
||||
enrich_artist_images: boolean;
|
||||
enrich_genres: boolean;
|
||||
enrich_cover_art: boolean;
|
||||
enrich_lyrics: boolean;
|
||||
@@ -846,7 +848,7 @@ export class EnrichmentService {
|
||||
}> {
|
||||
const rows = await this.pgClient.query(
|
||||
`SELECT key, value FROM settings
|
||||
WHERE key IN ('enrich_metadata','enrich_genres','enrich_cover_art',
|
||||
WHERE key IN ('enrich_metadata','enrich_artist_images','enrich_genres','enrich_cover_art',
|
||||
'enrich_lyrics','enrich_artist_similarity','enrich_audio_analysis')`
|
||||
);
|
||||
const map: Record<string, boolean> = {};
|
||||
@@ -855,6 +857,9 @@ export class EnrichmentService {
|
||||
}
|
||||
return {
|
||||
enrich_metadata: map.enrich_metadata ?? true,
|
||||
// This migration is intentionally opt-in: image cleanup must never be
|
||||
// followed by an unrequested external API fan-out on the next scan.
|
||||
enrich_artist_images: map.enrich_artist_images ?? false,
|
||||
enrich_genres: map.enrich_genres ?? true,
|
||||
enrich_cover_art: map.enrich_cover_art ?? true,
|
||||
enrich_lyrics: map.enrich_lyrics ?? true,
|
||||
@@ -867,25 +872,32 @@ export class EnrichmentService {
|
||||
* Fetch/refresh a single artist's image via the fallback chain. Runs as the
|
||||
* dedicated `artist_image` job so image lookups don't run inline with track
|
||||
* enrichment. Best-effort and idempotent — getArtistImage() short-circuits
|
||||
* when a good image already exists. Gated by enrich_metadata (same toggle the
|
||||
* inline step used).
|
||||
* when a good image already exists. Gated by the dedicated
|
||||
* enrich_artist_images toggle, independently of structural metadata.
|
||||
*/
|
||||
async refreshArtistImage(artistId: string): Promise<void> {
|
||||
async refreshArtistImage(artistId: string): Promise<EnrichmentJobOutcome> {
|
||||
const settings = await this.loadSettings();
|
||||
if (!settings.enrich_metadata) return;
|
||||
if (!settings.enrich_artist_images) {
|
||||
console.log(`[Enrich] artist image ${artistId}: skipped (enrich_artist_images disabled)`);
|
||||
return { outcome: 'skipped', detail: 'enrich_artist_images is disabled.' };
|
||||
}
|
||||
|
||||
const res = await this.pgClient.query<{ name: string; canonical_name: string | null; mbid: string | null }>(
|
||||
`SELECT name, canonical_name, mbid FROM artists WHERE id = $1`,
|
||||
const res = await this.pgClient.query<{ name: string; canonical_name: string | null; mbid: string | null; image_path: string | null }>(
|
||||
`SELECT name, canonical_name, mbid, image_path FROM artists WHERE id = $1`,
|
||||
[artistId]
|
||||
);
|
||||
const artist = res.rows[0];
|
||||
if (!artist) {
|
||||
console.warn(`[Enrich] artist not found for image: ${artistId}`);
|
||||
return;
|
||||
return { outcome: 'not_found', detail: 'Artist no longer exists.' };
|
||||
}
|
||||
if (artist.image_path) return { outcome: 'unchanged', detail: 'Artist already has an image.' };
|
||||
|
||||
const url = await this.getArtistImage(artistId, artist.mbid, artist.canonical_name ?? artist.name);
|
||||
console.log(`[Enrich] artist image ${artistId}: ${url ? 'set' : 'none'}`);
|
||||
return url
|
||||
? { outcome: 'updated', detail: 'Artist image was set.' }
|
||||
: { outcome: 'no_result', detail: 'No verified artist image was found.' };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -906,9 +918,11 @@ export class EnrichmentService {
|
||||
* release-group cover.
|
||||
* Each step short-circuits on the first hit.
|
||||
*/
|
||||
async refreshAlbumCover(albumId: string): Promise<void> {
|
||||
async refreshAlbumCover(albumId: string): Promise<EnrichmentJobOutcome> {
|
||||
const settings = await this.loadSettings();
|
||||
if (!settings.enrich_cover_art) return;
|
||||
if (!settings.enrich_cover_art) {
|
||||
return { outcome: 'skipped', detail: 'enrich_cover_art is disabled.' };
|
||||
}
|
||||
|
||||
const albumRes = await this.pgClient.query<{
|
||||
title: string;
|
||||
@@ -922,9 +936,9 @@ export class EnrichmentService {
|
||||
const album = albumRes.rows[0];
|
||||
if (!album) {
|
||||
console.warn(`[Enrich] album not found for cover: ${albumId}`);
|
||||
return;
|
||||
return { outcome: 'not_found', detail: 'Album no longer exists.' };
|
||||
}
|
||||
if (album.artwork_id) return; // already has cover — nothing to do
|
||||
if (album.artwork_id) return { outcome: 'unchanged', detail: 'Album already has artwork.' };
|
||||
|
||||
let artistName = '';
|
||||
if (album.artist_id) {
|
||||
@@ -947,7 +961,7 @@ export class EnrichmentService {
|
||||
[coverUrl, albumId]
|
||||
);
|
||||
console.log(`[Enrich] album cover ${albumId}: caa-release-group`);
|
||||
return;
|
||||
return { outcome: 'updated', detail: 'Cover Art Archive release-group.' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Enrich] album cover CAA release-group step failed:', (err as Error).message);
|
||||
@@ -965,7 +979,7 @@ export class EnrichmentService {
|
||||
[coverUrl, albumId]
|
||||
);
|
||||
console.log(`[Enrich] album cover ${albumId}: itunes`);
|
||||
return;
|
||||
return { outcome: 'updated', detail: 'iTunes artwork.' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Enrich] album cover iTunes step failed:', (err as Error).message);
|
||||
@@ -982,7 +996,7 @@ export class EnrichmentService {
|
||||
[deezerAlbum.coverXl, albumId]
|
||||
);
|
||||
console.log(`[Enrich] album cover ${albumId}: deezer`);
|
||||
return;
|
||||
return { outcome: 'updated', detail: 'Deezer artwork.' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Enrich] album cover Deezer step failed:', (err as Error).message);
|
||||
@@ -1006,7 +1020,7 @@ export class EnrichmentService {
|
||||
);
|
||||
}
|
||||
console.log(`[Enrich] album cover ${albumId}: discogs`);
|
||||
return;
|
||||
return { outcome: 'updated', detail: 'Discogs artwork.' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Enrich] album cover Discogs step failed:', (err as Error).message);
|
||||
@@ -1032,7 +1046,7 @@ export class EnrichmentService {
|
||||
[coverUrl, albumId]
|
||||
);
|
||||
console.log(`[Enrich] album cover ${albumId}: caa-release`);
|
||||
return;
|
||||
return { outcome: 'updated', detail: 'Cover Art Archive release.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1041,6 +1055,7 @@ export class EnrichmentService {
|
||||
}
|
||||
|
||||
console.log(`[Enrich] album cover ${albumId}: none`);
|
||||
return { outcome: 'no_result', detail: 'No cover provider returned artwork.' };
|
||||
}
|
||||
|
||||
async refreshArtistSimilarity(artistId: string): Promise<void> {
|
||||
|
||||
Vendored
-1
@@ -7,7 +7,6 @@ declare module 'essentia.js' {
|
||||
KeyExtractor(audio: any, averageDetuningCorrection?: boolean, frameSize?: number, hopSize?: number): { key: string; scale: string; strength: number };
|
||||
Energy(signal: any): { energy: number };
|
||||
Danceability(signal: any, maxTau?: number, minTau?: number, sampleRate?: number): { danceability: number; dfa: number[] };
|
||||
DynamicComplexity(signal: any, frameSize?: number, sampleRate?: number): { dynamicComplexity: number; loudness: number };
|
||||
}
|
||||
export { EssentiaWASM, Essentia };
|
||||
}
|
||||
|
||||
+112
-10
@@ -1,6 +1,6 @@
|
||||
import { Worker, Job } from 'bullmq';
|
||||
import { connection, QUEUE_NAME, queue } from './queue.js';
|
||||
import { MetadataRefreshJob, AudioAnalysisJob, LibraryScanJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, ReprocessArtistsJob } from './types.js';
|
||||
import { MetadataRefreshJob, AudioAnalysisJob, AudioAnalysisSweepJob, LibraryScanJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, ReprocessArtistsJob, AcquisitionJob } from './types.js';
|
||||
import { Pool } from 'pg';
|
||||
import { ScannerService } from './scanner.service.js';
|
||||
import { IntegrityService } from './integrity.service.js';
|
||||
@@ -8,6 +8,8 @@ import { EnrichmentService } from './enrichment.service.js';
|
||||
import { AudioFeaturesService } from './audio-features.service.js';
|
||||
import { CleanupSweepService } from './cleanup.service.js';
|
||||
import { reprocessArtists } from './reprocess-artists.service.js';
|
||||
import { AcquisitionService } from './acquisition.service.js';
|
||||
import { AUDIO_ANALYSIS_JOB_OPTIONS, AUDIO_ANALYSIS_VERSION, audioAnalysisJobId } from './audio-analysis.js';
|
||||
|
||||
// Cron for the periodic integrity sweep (default: daily at 03:00). Configurable
|
||||
// via INTEGRITY_SWEEP_CRON. MUSIC_DIR (consumed by IntegrityService) controls
|
||||
@@ -19,8 +21,31 @@ const CLEANUP_SWEEP_CRON = process.env.CLEANUP_SWEEP_CRON || '0 */6 * * *';
|
||||
// VIBE_REAP_CRON. Spec §4 / Invariant B: ACTIVE batches with no interaction for
|
||||
// 24h must transition to RESOLVED so returning users start fresh sessions.
|
||||
const VIBE_REAP_CRON = process.env.VIBE_REAP_CRON || '0 * * * *';
|
||||
const PROBATION_SWEEP_CRON = process.env.PROBATION_SWEEP_CRON || '15 * * * *';
|
||||
// A small daily backfill is intentionally bounded. It refreshes stale v1
|
||||
// measurements over time without turning a worker restart into a library-wide
|
||||
// ffmpeg/Essentia batch.
|
||||
const AUDIO_ANALYSIS_SWEEP_CRON = process.env.AUDIO_ANALYSIS_SWEEP_CRON || '20 4 * * *';
|
||||
const configuredAudioBatchSize = Number.parseInt(process.env.AUDIO_ANALYSIS_BATCH_SIZE || '25', 10);
|
||||
const AUDIO_ANALYSIS_BATCH_SIZE = Number.isFinite(configuredAudioBatchSize)
|
||||
? Math.min(100, Math.max(1, configuredAudioBatchSize))
|
||||
: 25;
|
||||
// Worker concurrency - how many jobs to process in parallel
|
||||
const WORKER_CONCURRENCY = parseInt(process.env.WORKER_CONCURRENCY || '10', 10);
|
||||
const AUDIO_ANALYSIS_CONCURRENCY = Math.max(1, Math.min(2, parseInt(process.env.AUDIO_ANALYSIS_CONCURRENCY || '1', 10) || 1));
|
||||
let activeAudioAnalyses = 0;
|
||||
const audioWaiters: Array<() => void> = [];
|
||||
async function withAudioSlot<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (activeAudioAnalyses >= AUDIO_ANALYSIS_CONCURRENCY) {
|
||||
await new Promise<void>((resolve) => audioWaiters.push(resolve));
|
||||
}
|
||||
activeAudioAnalyses++;
|
||||
try { return await fn(); }
|
||||
finally {
|
||||
activeAudioAnalyses--;
|
||||
audioWaiters.shift()?.();
|
||||
}
|
||||
}
|
||||
|
||||
// A Pool, not a single Client. The worker processes jobs with
|
||||
// `concurrency: 10` on one event loop, so a shared Client would multiplex every
|
||||
@@ -48,6 +73,7 @@ async function initWorker() {
|
||||
console.log('Worker connected to PostgreSQL');
|
||||
|
||||
const scannerService = new ScannerService(pgPool, queue);
|
||||
const acquisitionService = new AcquisitionService(pgPool, scannerService);
|
||||
const enrichmentService = new EnrichmentService(pgPool);
|
||||
const audioFeaturesService = new AudioFeaturesService(pgPool);
|
||||
await audioFeaturesService.ensureSchema();
|
||||
@@ -78,9 +104,9 @@ async function initWorker() {
|
||||
// Real best-effort enrichment via the external-integration clients.
|
||||
// Each provider is isolated inside the service so one failing source
|
||||
// never aborts the others or fails the job.
|
||||
await enrichmentService.enrichTrack(payload.trackId);
|
||||
console.log(`[Metadata] Successfully refreshed track: ${payload.trackId}`);
|
||||
break;
|
||||
const outcome = await enrichmentService.enrichTrack(payload.trackId);
|
||||
console.log(`[Metadata] ${payload.trackId}: ${outcome.outcome}`);
|
||||
return outcome;
|
||||
}
|
||||
case 'artist_similarity': {
|
||||
const payload = job.data as ArtistSimilarityJob;
|
||||
@@ -91,22 +117,55 @@ async function initWorker() {
|
||||
case 'artist_image': {
|
||||
const payload = job.data as ArtistImageJob;
|
||||
console.log(`[ArtistImage] Refreshing image for artist: ${payload.artistId}`);
|
||||
await enrichmentService.refreshArtistImage(payload.artistId);
|
||||
break;
|
||||
return await enrichmentService.refreshArtistImage(payload.artistId);
|
||||
}
|
||||
case 'album_cover': {
|
||||
const payload = job.data as AlbumCoverJob;
|
||||
console.log(`[AlbumCover] Refreshing cover for album: ${payload.albumId}`);
|
||||
await enrichmentService.refreshAlbumCover(payload.albumId);
|
||||
break;
|
||||
return await enrichmentService.refreshAlbumCover(payload.albumId);
|
||||
}
|
||||
case 'audio_analysis': {
|
||||
const payload = job.data as AudioAnalysisJob;
|
||||
console.log(`[Audio] Analyzing track: ${payload.trackId}`);
|
||||
await audioFeaturesService.extractAndPersist(payload.trackId);
|
||||
await withAudioSlot(() => audioFeaturesService.extractAndPersist(payload.trackId));
|
||||
console.log(`[Audio] Successfully analyzed track: ${payload.trackId}`);
|
||||
break;
|
||||
}
|
||||
case 'audio_analysis_sweep': {
|
||||
const payload = job.data as AudioAnalysisSweepJob;
|
||||
const enabledResult = await pgPool.query<{ value: string }>(
|
||||
"SELECT value FROM settings WHERE key = 'enrich_audio_analysis'"
|
||||
);
|
||||
if (enabledResult.rows[0]?.value !== 'true') {
|
||||
console.log('[Audio] Sweep skipped: enrich_audio_analysis is disabled');
|
||||
break;
|
||||
}
|
||||
|
||||
const requestedLimit = payload.limit ?? AUDIO_ANALYSIS_BATCH_SIZE;
|
||||
const limit = Math.min(100, Math.max(1, Number.isFinite(requestedLimit) ? requestedLimit : AUDIO_ANALYSIS_BATCH_SIZE));
|
||||
const stale = await pgPool.query<{ id: string }>(
|
||||
`SELECT t.id
|
||||
FROM tracks t
|
||||
LEFT JOIN track_audio_features af ON af.track_id = t.id
|
||||
WHERE t.state IN ('LIBRARY', 'RECOMMENDED')
|
||||
AND (
|
||||
af.track_id IS NULL
|
||||
OR af.analysis_version < $1
|
||||
OR af.source_hash IS DISTINCT FROM t.hash
|
||||
)
|
||||
ORDER BY COALESCE(af.analyzed_at, to_timestamp(0)), t.id
|
||||
LIMIT $2`,
|
||||
[AUDIO_ANALYSIS_VERSION, limit]
|
||||
);
|
||||
for (const track of stale.rows) {
|
||||
await queue.add('audio_analysis', { trackId: track.id } satisfies AudioAnalysisJob, {
|
||||
jobId: audioAnalysisJobId(track.id),
|
||||
...AUDIO_ANALYSIS_JOB_OPTIONS,
|
||||
});
|
||||
}
|
||||
console.log(`[Audio] Sweep enqueued ${stale.rows.length} track(s)`);
|
||||
break;
|
||||
}
|
||||
case 'integrity_sweep': {
|
||||
// Periodic self-healing pass: detect corrupt/missing track metadata,
|
||||
// auto-fix via rescan + SQL strip, flag the rest for manual review.
|
||||
@@ -182,7 +241,7 @@ async function initWorker() {
|
||||
`SELECT t.id, t.title, t.artist, al.title AS album, t.duration, t.play_count, t.source_type
|
||||
FROM tracks t
|
||||
LEFT JOIN albums al ON al.id = t.album_id
|
||||
WHERE t.state = 'LIBRARY'`
|
||||
WHERE t.state IN ('LIBRARY', 'RECOMMENDED')`
|
||||
);
|
||||
const tracks = tracksRes.rows;
|
||||
|
||||
@@ -227,6 +286,35 @@ async function initWorker() {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'acquire_discovery_candidate': {
|
||||
const payload = job.data as AcquisitionJob;
|
||||
const result = await acquisitionService.acquire(payload.candidateId);
|
||||
console.log(`[Acquisition] Candidate ${payload.candidateId}: ${result.status}${'reason' in result ? ` (${result.reason})` : ''}`);
|
||||
if (result.status === 'failed') throw new Error(result.reason);
|
||||
return result;
|
||||
}
|
||||
case 'probation_sweep': {
|
||||
// Keep probation moving without exposing an operator-only HTTP endpoint
|
||||
// as the sole lifecycle driver. These conditions mirror DiscoveryService.
|
||||
const retained = await pgPool.query(
|
||||
`UPDATE tracks t SET probation_status = 'retained', state = 'LIBRARY'
|
||||
WHERE t.probation_status = 'probation'
|
||||
AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
|
||||
AND e.entity_id = t.id AND e.signal = 'playback_completed') >= 3`
|
||||
);
|
||||
const retired = await pgPool.query(
|
||||
`UPDATE tracks t SET probation_status = 'retired', state = 'HIDDEN'
|
||||
WHERE t.probation_status = 'probation'
|
||||
AND t.probation_entered_at < NOW() - INTERVAL '7 days'
|
||||
AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
|
||||
AND e.entity_id = t.id AND e.signal = 'playback_completed') = 0
|
||||
AND (SELECT COUNT(*) FROM evidence e WHERE e.entity_type = 'track'
|
||||
AND e.entity_id = t.id AND e.signal = 'skip_quick') >= 3`
|
||||
);
|
||||
const result = { retained: retained.rowCount ?? 0, retired: retired.rowCount ?? 0 };
|
||||
console.log(`[Probation] Sweep retained=${result.retained} retired=${result.retired}`);
|
||||
return result;
|
||||
}
|
||||
default:
|
||||
console.log(`Received job of type: ${job.name} with data:`, job.data);
|
||||
break;
|
||||
@@ -268,6 +356,20 @@ async function initWorker() {
|
||||
);
|
||||
console.log(`[VibeReap] Reaper scheduled with cron: ${VIBE_REAP_CRON}`);
|
||||
|
||||
await queue.upsertJobScheduler(
|
||||
'probation-sweep',
|
||||
{ pattern: PROBATION_SWEEP_CRON },
|
||||
{ name: 'probation_sweep', data: { reason: 'scheduled' } }
|
||||
);
|
||||
console.log(`[Probation] Sweep scheduled with cron: ${PROBATION_SWEEP_CRON}`);
|
||||
|
||||
await queue.upsertJobScheduler(
|
||||
'audio-analysis-sweep',
|
||||
{ pattern: AUDIO_ANALYSIS_SWEEP_CRON },
|
||||
{ name: 'audio_analysis_sweep', data: { reason: 'scheduled', limit: AUDIO_ANALYSIS_BATCH_SIZE } satisfies AudioAnalysisSweepJob }
|
||||
);
|
||||
console.log(`[Audio] Bounded analysis sweep scheduled with cron: ${AUDIO_ANALYSIS_SWEEP_CRON}, batch: ${AUDIO_ANALYSIS_BATCH_SIZE}`);
|
||||
|
||||
// Enqueue a startup scan only when the database is empty (fresh volume after
|
||||
// deleting data/postgres, or first deploy). On subsequent restarts the library
|
||||
// is already populated, so an unconditional scan would be wasteful.
|
||||
|
||||
+102
-11
@@ -5,9 +5,25 @@ import path from 'path';
|
||||
import mm from 'music-metadata';
|
||||
import type { Queryable } from './db.js';
|
||||
import { Queue } from 'bullmq';
|
||||
import { MetadataRefreshJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob } from './types.js';
|
||||
import { MetadataRefreshJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob, AudioAnalysisJob } from './types.js';
|
||||
import { AUDIO_ANALYSIS_JOB_OPTIONS, audioAnalysisJobId, AUDIO_ANALYSIS_VERSION } from './audio-analysis.js';
|
||||
import { splitArtistNames, parseArtists } from './utils/artist-names.js';
|
||||
|
||||
/**
|
||||
* Scanner provenance is supplied by the acquisition worker, not inferred from
|
||||
* tags. The optional candidate id lets the acquisition service make an exact
|
||||
* candidate -> scanned-track association after a successful scan.
|
||||
*/
|
||||
export interface ScanContext {
|
||||
sourceType?: 'MANUAL' | 'RECOMMENDATION';
|
||||
probationStatus?: 'probation' | 'retained' | 'retired';
|
||||
candidateId?: string;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
trackIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse main + featured artists from music-metadata. Prefers the structured
|
||||
* `artists[]` array when the tag provides it (each entry already one artist),
|
||||
@@ -79,26 +95,31 @@ export class ScannerService {
|
||||
// pending jobs; these avoid even issuing the redundant add() within one scan).
|
||||
private enqueuedArtists = new Set<string>();
|
||||
private enqueuedAlbums = new Set<string>();
|
||||
private audioAnalysisEnabled = false;
|
||||
|
||||
constructor(private pgClient: Queryable, private queue: Queue) {}
|
||||
|
||||
async scanDirectory(directory: string) {
|
||||
async scanDirectory(directory: string, context: ScanContext = {}): Promise<ScanResult> {
|
||||
console.log(`[Scanner] Starting scan in: ${directory}`);
|
||||
this.enqueuedArtists.clear();
|
||||
this.enqueuedAlbums.clear();
|
||||
await this.walk(directory);
|
||||
this.audioAnalysisEnabled = await this.loadAudioAnalysisSetting();
|
||||
const trackIds: string[] = [];
|
||||
await this.walk(directory, context, trackIds);
|
||||
console.log(`[Scanner] Scan completed.`);
|
||||
return { trackIds };
|
||||
}
|
||||
|
||||
private async walk(dir: string) {
|
||||
private async walk(dir: string, context: ScanContext, trackIds: string[]) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await this.walk(fullPath);
|
||||
await this.walk(fullPath, context, trackIds);
|
||||
} else if (this.isMusicFile(entry.name)) {
|
||||
await this.processFile(fullPath);
|
||||
const trackId = await this.processFile(fullPath, context);
|
||||
if (trackId) trackIds.push(trackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +129,7 @@ export class ScannerService {
|
||||
return extensions.includes(path.extname(fileName).toLowerCase());
|
||||
}
|
||||
|
||||
private async processFile(filePath: string) {
|
||||
private async processFile(filePath: string, context: ScanContext): Promise<string | null> {
|
||||
try {
|
||||
console.log(`[Scanner] Processing: ${filePath}`);
|
||||
const metadata = await mm.parseFile(filePath);
|
||||
@@ -159,19 +180,44 @@ export class ScannerService {
|
||||
const duration = format.duration || 0;
|
||||
const fileHash = await hashFile(filePath);
|
||||
|
||||
// A recommendation scan sets provenance at creation time. A routine
|
||||
// library rescan must never erase that provenance or reset probation.
|
||||
const sourceType = context.sourceType ?? 'MANUAL';
|
||||
const state = sourceType === 'RECOMMENDATION' ? 'RECOMMENDED' : 'LIBRARY';
|
||||
const probationStatus = sourceType === 'RECOMMENDATION'
|
||||
? (context.probationStatus ?? 'probation')
|
||||
: 'retained';
|
||||
const trackRes = await this.pgClient.query(
|
||||
`INSERT INTO tracks (path, hash, title, artist, album_id, duration, state)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'LIBRARY')
|
||||
`INSERT INTO tracks (
|
||||
path, hash, title, artist, album_id, duration, state, source_type,
|
||||
probation_status, probation_entered_at
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7::track_state, $8::track_source_type,
|
||||
$9, CASE WHEN $8::track_source_type = 'RECOMMENDATION' THEN NOW() ELSE NULL END
|
||||
)
|
||||
ON CONFLICT (path) DO UPDATE SET
|
||||
hash = EXCLUDED.hash,
|
||||
title = EXCLUDED.title,
|
||||
artist = EXCLUDED.artist,
|
||||
album_id = EXCLUDED.album_id,
|
||||
duration = EXCLUDED.duration,
|
||||
mtime = EXTRACT(EPOCH FROM NOW())
|
||||
mtime = EXTRACT(EPOCH FROM NOW()),
|
||||
-- Existing recommendation rows stay recommendations during every
|
||||
-- ordinary scan. This is load-bearing for probation and Vibe.
|
||||
state = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
THEN tracks.state ELSE EXCLUDED.state END,
|
||||
source_type = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
THEN tracks.source_type ELSE $8::track_source_type END,
|
||||
probation_status = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
THEN tracks.probation_status ELSE $9 END,
|
||||
probation_entered_at = CASE WHEN tracks.source_type = 'RECOMMENDATION'
|
||||
THEN tracks.probation_entered_at
|
||||
WHEN $8::track_source_type = 'RECOMMENDATION' THEN NOW()
|
||||
ELSE tracks.probation_entered_at END
|
||||
RETURNING id
|
||||
`,
|
||||
[filePath, fileHash, trackTitle, resolvedArtist, albumId, duration]
|
||||
[filePath, fileHash, trackTitle, resolvedArtist, albumId, duration, state, sourceType, probationStatus]
|
||||
);
|
||||
const trackId = String(trackRes.rows[0].id);
|
||||
|
||||
@@ -196,8 +242,10 @@ export class ScannerService {
|
||||
// Trigger external-API enrichment for this track + artist + album.
|
||||
// Best-effort: an enqueue failure must never abort the scan of remaining files.
|
||||
await this.enqueueEnrichment(trackId, String(artistId), String(albumId));
|
||||
return trackId;
|
||||
} catch (err) {
|
||||
console.error(`[Scanner] Error processing ${filePath}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +306,35 @@ export class ScannerService {
|
||||
removeOnFail: { age: 86400, count: 5000 },
|
||||
} as const;
|
||||
|
||||
// Audio decoding is CPU/memory intensive. It must never be performed inline
|
||||
// with metadata refreshes: enqueue one deduplicated, retryable job only when
|
||||
// the user has enabled it and the file has changed or lacks the current
|
||||
// analysis version.
|
||||
if (this.audioAnalysisEnabled) {
|
||||
try {
|
||||
const current = await this.pgClient.query<{ current: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM tracks t
|
||||
JOIN track_audio_features af ON af.track_id = t.id
|
||||
WHERE t.id = $1
|
||||
AND af.analysis_version >= $2
|
||||
AND af.source_hash = t.hash
|
||||
) AS current`,
|
||||
[trackId, AUDIO_ANALYSIS_VERSION]
|
||||
);
|
||||
if (!current.rows[0]?.current) {
|
||||
const payload: AudioAnalysisJob = { trackId };
|
||||
await this.queue.add('audio_analysis', payload, {
|
||||
jobId: audioAnalysisJobId(trackId),
|
||||
...AUDIO_ANALYSIS_JOB_OPTIONS,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Scanner] Failed to enqueue audio_analysis for track ${trackId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// metadata_refresh per track. jobId `meta-<trackId>` collapses duplicate
|
||||
// pending jobs across re-scans; the handler (enrichTrack) is idempotent so
|
||||
// re-enqueues are always safe. BullMQ 5.x rejects colons in custom ids.
|
||||
@@ -298,4 +375,18 @@ export class ScannerService {
|
||||
console.error(`[Scanner] Failed to enqueue artist_image for artist ${artistId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAudioAnalysisSetting(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.pgClient.query<{ value: string }>(
|
||||
"SELECT value FROM settings WHERE key = 'enrich_audio_analysis'"
|
||||
);
|
||||
return result.rows[0]?.value === 'true';
|
||||
} catch (err) {
|
||||
// Safe default: a schema/startup problem must not fan out expensive DSP
|
||||
// work across a scan.
|
||||
console.warn('[Scanner] Audio analysis disabled: unable to read setting:', (err as Error).message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -5,7 +5,12 @@ export interface MetadataRefreshJob {
|
||||
|
||||
export interface AudioAnalysisJob {
|
||||
trackId: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
/** A bounded sweep is used for existing tracks, never an unbounded startup job. */
|
||||
export interface AudioAnalysisSweepJob {
|
||||
reason?: 'scheduled' | 'manual';
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CleanupJob {
|
||||
@@ -48,13 +53,20 @@ export interface ReprocessArtistsJob {
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** System E acquisition job. Candidate details stay in Postgres, not Redis. */
|
||||
export interface AcquisitionJob {
|
||||
candidateId: string;
|
||||
}
|
||||
|
||||
export type JobPayload =
|
||||
| MetadataRefreshJob
|
||||
| ArtistSimilarityJob
|
||||
| ArtistImageJob
|
||||
| AlbumCoverJob
|
||||
| AudioAnalysisJob
|
||||
| AudioAnalysisSweepJob
|
||||
| CleanupJob
|
||||
| LibraryScanJob
|
||||
| IntegritySweepJob
|
||||
| ReprocessArtistsJob;
|
||||
| ReprocessArtistsJob
|
||||
| AcquisitionJob;
|
||||
|
||||
Reference in New Issue
Block a user