feat: enhance discovery, vibe sessions, and library enrichment
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled

This commit is contained in:
kami
2026-08-01 14:40:48 +04:00
parent a0c9f42a89
commit 4c48d11e9d
54 changed files with 4136 additions and 521 deletions
+6 -1
View File
@@ -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
+289
View File
@@ -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 };
}
}
}
+37
View File
@@ -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 });
});
+64
View File
@@ -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}`;
}
+71 -42
View File
@@ -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[] = [];
+51 -36
View File
@@ -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> {
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;