initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.git
.env
+16
View File
@@ -0,0 +1,16 @@
# --- Core worker ---
DATABASE_URL=postgresql://user:password@db:5432/muzick # Postgres connection string
REDIS_URL=redis://redis:6379 # Redis (BullMQ) connection string
MUSIC_DIR=/music # Root music library path scanned by the worker
INTEGRITY_SWEEP_CRON=0 3 * * * # Cron for the periodic integrity sweep
# --- External enrichment integrations ---
MUSICBRAINZ_CONTACT=you@example.com # Contact (email/URL) for the required MusicBrainz User-Agent
LASTFM_API_KEY=your-lastfm-api-key # Last.fm API key (enrichment is disabled if empty)
LASTFM_SHARED_SECRET=your-lastfm-shared-secret # Last.fm shared secret (optional; only for authenticated calls)
DISCOGS_TOKEN=your-discogs-token # Discogs personal access token (enrichment is disabled if empty)
# --- Cleanup sweep (dislike lifecycle) ---
CLEANUP_SWEEP_CRON=0 */6 * * * # Cron for the dislike grace-period cleanup sweep
NTFY_URL=https://ntfy.sh # ntfy server base URL (leave empty to disable notifications)
NTFY_TOPIC=muzick # ntfy topic to publish deletion warnings to
+9
View File
@@ -0,0 +1,9 @@
FROM node:20-slim
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start"]
+1832
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "muzick-worker",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"repair:metadata": "node dist/scripts/repair-corrupted-metadata.js",
"split:artists": "tsx src/scripts/split-collab-artists.ts",
"prebuild": "tsc --noEmit",
"build": "tsc"
},
"dependencies": {
"bullmq": "^5.1.0",
"essentia.js": "^0.1.3",
"music-metadata": "^7.11.0",
"pg": "^8.21.0",
"redis": "^5.0.0",
"socks-proxy-agent": "^10.0.0",
"typesense": "^3.0.6"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/pg": "^8.20.0",
"tsx": "^4.6.2",
"typescript": "^5.3.3"
}
}
+233
View File
@@ -0,0 +1,233 @@
/**
* AudioFeaturesService — two-pass audio feature extraction.
*
* Pass 1 (fast): read BPM and key from embedded file tags via music-metadata.
* Many files tagged by beets / MusicBrainz Picard / Mixed In Key already
* carry these, so we get the answer for free.
*
* Pass 2 (compute): run essentia.js (WebAssembly) on the decoded PCM signal
* for anything still missing. Audio decoding is done by a ffmpeg subprocess
* so every format ffmpeg understands (MP3, FLAC, AAC, OGG, …) is supported.
* Computed features: BPM, key, energy, danceability, dynamic complexity.
*
* The two passes are combined via COALESCE so that embedded-tag values are
* never overwritten once they exist.
*/
import { spawn } from 'child_process';
import mm from 'music-metadata';
import { Client as PgClient } from 'pg';
// Lazy WASM singleton — heavy to load (~2.4 MB), so we initialise once and
// reuse across all enrichment jobs within the same worker process.
let essentiaReady: Promise<{ essentia: any }> | null = null;
async function getEssentia() {
if (!essentiaReady) {
essentiaReady = (async () => {
const mod = await import('essentia.js');
// essentia.js v0.1.3: default export contains { Essentia, EssentiaWASM, ... }
// EssentiaWASM is already the instantiated WASM module (not a function)
const EssentiaWASM = mod.default?.EssentiaWASM ?? mod.EssentiaWASM;
const Essentia = mod.default?.Essentia ?? mod.Essentia;
if (!EssentiaWASM || !Essentia) {
throw new Error('essentia.js exports missing EssentiaWASM or Essentia');
}
const essentia = new Essentia(EssentiaWASM);
return { essentia };
})();
}
return essentiaReady;
}
/** Decode any audio file to a mono Float32Array at 44100 Hz via ffmpeg. */
function decodeAudioToFloat32(filePath: string): Promise<Float32Array> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const ff = spawn('ffmpeg', [
'-i', filePath,
'-vn',
'-acodec', 'pcm_f32le',
'-ar', '44100',
'-ac', '1',
'-f', 'f32le',
'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}`));
}
});
});
}
export interface AudioFeatures {
bpm: number | null;
key: string | null;
energy: number | null;
danceability: number | null;
dynamicComplexity: number | null;
}
export class AudioFeaturesService {
constructor(private pgClient: PgClient) {}
async ensureSchema(): Promise<void> {
await this.pgClient.query(
`CREATE TABLE IF NOT EXISTS track_audio_features (
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
bpm REAL,
key TEXT,
energy REAL,
danceability REAL,
valence REAL,
acousticness REAL,
instrumentalness REAL,
liveness REAL,
valence_score REAL,
tempo REAL
)`
);
}
// ── Pass 1: embedded tags ──────────────────────────────────────────────────
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 rgRaw = (common as any).replaygain_track_gain;
let replayGainDb: number | null = null;
if (rgRaw != null) {
const val = typeof rgRaw === 'object' ? rgRaw.dB : Number(String(rgRaw).replace(/[^\d.-]/g, ''));
if (Number.isFinite(val)) replayGainDb = val;
}
return { bpm, key, replayGainDb };
} catch {
return { bpm: null, key: null, replayGainDb: null };
}
}
// ── 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 { essentia } = await getEssentia();
const signal = await decodeAudioToFloat32(filePath);
if (signal.length === 0) return result;
const vec = essentia.arrayToVector(signal);
if (needBpm) {
try {
const rhythm = essentia.RhythmExtractor2013(vec);
if (rhythm.bpm > 0) result.bpm = Math.round(rhythm.bpm * 10) / 10;
} catch (err) {
console.warn('[AudioFeatures] RhythmExtractor2013 failed:', (err as Error).message);
}
}
if (needKey) {
try {
const keyResult = essentia.KeyExtractor(vec);
if (keyResult.key) result.key = `${keyResult.key} ${keyResult.scale}`;
} catch (err) {
console.warn('[AudioFeatures] KeyExtractor failed:', (err as Error).message);
}
}
// 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
} 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);
} 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;
}
// ── Public entry point ─────────────────────────────────────────────────────
async extractAndPersist(trackId: string): Promise<void> {
const pathRes = await this.pgClient.query<{ path: string }>(
'SELECT path FROM tracks WHERE id = $1',
[trackId]
);
const row = pathRes.rows[0];
if (!row) {
console.warn(`[AudioFeatures] track not found: ${trackId}`);
return;
}
const tags = await this.readEmbeddedTags(row.path);
// Rough energy proxy from ReplayGain when available (free, no DSP).
let tagEnergy: number | null = null;
if (tags.replayGainDb !== null) {
tagEnergy = Math.min(1, Math.max(0, (tags.replayGainDb + 18) / 21));
}
// Only spin up Essentia when embedded tags are missing something useful.
const needBpm = tags.bpm === null;
const needKey = tags.key === null;
// Always run Essentia for energy/danceability since no tag source covers those.
const dsq = await this.analyseWithEssentia(row.path, needBpm, needKey);
const bpm = tags.bpm ?? dsq.bpm;
const key = tags.key ?? dsq.key;
const energy = tagEnergy ?? dsq.energy;
const danceability = dsq.danceability;
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)
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]
);
const parts: string[] = [];
if (bpm) parts.push(`bpm:${bpm.toFixed(1)}${tags.bpm ? '(tag)' : '(dsp)'}`);
if (key) parts.push(`key:${key}${tags.key ? '(tag)' : '(dsp)'}`);
if (energy !== null) parts.push(`energy:${energy.toFixed(2)}`);
if (danceability !== null) parts.push(`dance:${danceability.toFixed(2)}`);
console.log(
`[AudioFeatures] ${trackId}: ${parts.length ? parts.join(', ') : 'no features extracted'}`
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import { Client as PgClient } from 'pg';
import { unlink } from 'fs/promises';
const NTFY_URL = process.env.NTFY_URL || '';
const NTFY_TOPIC = process.env.NTFY_TOPIC || 'muzick';
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
async function sendNtfy(title: string, message: string): Promise<void> {
if (!NTFY_URL) return;
try {
await fetch(`${NTFY_URL}/${NTFY_TOPIC}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, message, priority: 3 }),
});
} catch (err) {
console.warn('[Cleanup] ntfy send failed:', err);
}
}
export class CleanupSweepService {
constructor(private pgClient: PgClient) {}
async runSweep(): Promise<{ warned: number; deleted: number }> {
const warned = await this.advanceToWarned();
const deleted = await this.finalizeDeleted();
return { warned, deleted };
}
/**
* Find HIDDEN dislikes past their grace period → send ntfy warning, mark WARNED.
*/
private async advanceToWarned(): Promise<number> {
const res = await this.pgClient.query<{ track_id: string; track_title: string; track_artist: string }>(
`SELECT d.track_id, t.title AS track_title, t.artist AS track_artist
FROM dislikes d
JOIN tracks t ON t.id = d.track_id
WHERE d.state = 'HIDDEN'
AND NOW() > d.disliked_at + (d.grace_hours || ' hours')::interval`
);
for (const row of res.rows) {
await this.pgClient.query(
`UPDATE dislikes SET warned_at = NOW(), state = 'WARNED' WHERE track_id = $1`,
[row.track_id]
);
await sendNtfy(
'Track scheduled for deletion',
`"${row.track_title}" by ${row.track_artist} will be permanently deleted in 24 hours. Restore it from Quarantine to keep it.`
);
console.log(`[Cleanup] Warned: ${row.track_title} (${row.track_id})`);
}
return res.rows.length;
}
/**
* Find WARNED dislikes where 24h has passed since warned_at → delete file + DB record.
*/
private async finalizeDeleted(): Promise<number> {
const res = await this.pgClient.query<{ track_id: string; track_path: string; track_title: string }>(
`SELECT d.track_id, t.path AS track_path, t.title AS track_title
FROM dislikes d
JOIN tracks t ON t.id = d.track_id
WHERE d.state = 'WARNED'
AND NOW() > d.warned_at + INTERVAL '24 hours'`
);
let deleted = 0;
for (const row of res.rows) {
try {
await unlink(row.track_path);
} catch (err: any) {
if (err.code !== 'ENOENT') {
console.error(`[Cleanup] Failed to delete file ${row.track_path}:`, err);
continue;
}
}
try {
await this.pgClient.query('BEGIN');
await this.pgClient.query(
`INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')`,
[SYSTEM_USER_ID, row.track_id]
);
await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [row.track_id]);
await this.pgClient.query('COMMIT');
deleted++;
console.log(`[Cleanup] Permanently deleted: ${row.track_title} (${row.track_id})`);
} catch (err) {
await this.pgClient.query('ROLLBACK');
console.error(`[Cleanup] DB deletion failed for ${row.track_id}:`, err);
}
}
return deleted;
}
}
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
declare module 'essentia.js' {
const EssentiaWASM: () => Promise<any>;
class Essentia {
constructor(wasmModule: any, isDebug?: boolean);
arrayToVector(arr: Float32Array): any;
RhythmExtractor2013(signal: any, maxTempo?: number, method?: string, minTempo?: number): { bpm: number; ticks: number[]; confidence: number };
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 };
}
+478
View File
@@ -0,0 +1,478 @@
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 { Client as PgClient } from 'pg';
import { ScannerService } from './scanner.service.js';
import { IntegrityService } from './integrity.service.js';
import { EnrichmentService } from './enrichment.service.js';
import { AudioFeaturesService } from './audio-features.service.js';
import { CleanupSweepService } from './cleanup.service.js';
// Cron for the periodic integrity sweep (default: daily at 03:00). Configurable
// via INTEGRITY_SWEEP_CRON. MUSIC_DIR (consumed by IntegrityService) controls
// the rescan root.
const INTEGRITY_SWEEP_CRON = process.env.INTEGRITY_SWEEP_CRON || '0 3 * * *';
// Cron for the dislike cleanup sweep (default: every 6 hours). Configurable via CLEANUP_SWEEP_CRON.
const CLEANUP_SWEEP_CRON = process.env.CLEANUP_SWEEP_CRON || '0 */6 * * *';
// Cron for the stale vibe-session reaper (default: hourly). Configurable via
// 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 * * * *';
// Worker concurrency - how many jobs to process in parallel
const WORKER_CONCURRENCY = parseInt(process.env.WORKER_CONCURRENCY || '10', 10);
const pgClient = new PgClient({
connectionString: process.env.DATABASE_URL,
});
async function initWorker() {
await pgClient.connect();
console.log('Worker connected to PostgreSQL');
const scannerService = new ScannerService(pgClient, queue);
const enrichmentService = new EnrichmentService(pgClient);
const audioFeaturesService = new AudioFeaturesService(pgClient);
await audioFeaturesService.ensureSchema();
// Self-provision the enrichment schema additions (artist_similar + reused
// columns) at startup, alongside the integrity table.
await enrichmentService.ensureSchema();
await new IntegrityService(pgClient, queue).ensureSchema();
const worker = new Worker(QUEUE_NAME, async (job: Job<any>) => {
// console.log(`Processing job: ${job.name} (ID: ${job.id})`);
console.log(`Processing job: ${job.name} (ID: ${job.id})`);
switch (job.name) {
case 'scan_library': {
const payload = job.data as LibraryScanJob;
console.log(`[Scanner] Starting library scan in: ${payload.directory}`);
await scannerService.scanDirectory(payload.directory);
// Re-sync Typesense after every scan so search reflects new/changed tracks.
await queue.add('reindex_tracks', {}, { removeOnComplete: { age: 86400, count: 100 }, removeOnFail: { age: 86400, count: 100 } });
console.log(`[Scanner] Enqueued Typesense reindex after scan`);
break;
}
case 'metadata_refresh': {
const payload = job.data as MetadataRefreshJob;
console.log(`[Metadata] Refreshing track: ${payload.trackId} (Mode: ${payload.refreshType})`);
// 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;
}
case 'artist_similarity': {
const payload = job.data as ArtistSimilarityJob;
console.log(`[Similarity] Refreshing similar artists for: ${payload.artistId}`);
await enrichmentService.refreshArtistSimilarity(payload.artistId);
break;
}
case 'artist_image': {
const payload = job.data as ArtistImageJob;
console.log(`[ArtistImage] Refreshing image for artist: ${payload.artistId}`);
await enrichmentService.refreshArtistImage(payload.artistId);
break;
}
case 'album_cover': {
const payload = job.data as AlbumCoverJob;
console.log(`[AlbumCover] Refreshing cover for album: ${payload.albumId}`);
await enrichmentService.refreshAlbumCover(payload.albumId);
break;
}
case 'audio_analysis': {
const payload = job.data as AudioAnalysisJob;
console.log(`[Audio] Analyzing track: ${payload.trackId}`);
await audioFeaturesService.extractAndPersist(payload.trackId);
console.log(`[Audio] Successfully analyzed track: ${payload.trackId}`);
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.
console.log('[Integrity] Starting integrity sweep');
const integrityService = new IntegrityService(pgClient, queue);
const summary = await integrityService.runSweep();
console.log(
`[Integrity] Sweep complete: detected=${summary.detected} fixed=${summary.fixed} needsReview=${summary.needsReview}`
);
break;
}
case 'cleanup_sweep': {
console.log('[Cleanup] Starting dislike cleanup sweep');
const cleanupService = new CleanupSweepService(pgClient);
const result = await cleanupService.runSweep();
console.log(`[Cleanup] Sweep complete: warned=${result.warned} deleted=${result.deleted}`);
break;
}
case 'vibe_reap': {
// Stale-session reaper (Invariant B). Transitions ACTIVE batches with no
// interaction for 24h to RESOLVED. Runs hourly; the SQL is idempotent.
const reaped = await pgClient.query(
`UPDATE recommendation_batch
SET status = 'RESOLVED'
WHERE status = 'ACTIVE'
AND last_interaction_at < NOW() - INTERVAL '24 hours'`
);
const n = reaped.rowCount ?? 0;
if (n > 0) console.log(`[VibeReap] Reaped ${n} stale ACTIVE session(s)`);
break;
}
case 'reindex_tracks': {
// Sync all LIBRARY tracks from Postgres into the Typesense 'tracks'
// collection. Inlined to avoid duplicating a service module.
const { Client } = await import('typesense');
const searchHost = process.env.TYPESENSE_HOST || 'localhost';
const searchPort = parseInt(process.env.TYPESENSE_PORT || '8108', 10);
const searchApiKey = process.env.TYPESENSE_API_KEY || '';
const tsClient = new Client({
nodes: [{ host: searchHost, port: searchPort, protocol: 'http' }],
apiKey: searchApiKey,
});
// Ensure collection schema exists.
try {
await tsClient.collections().create({
name: 'tracks',
fields: [
{ name: 'id', type: 'string' },
{ name: 'title', type: 'string' },
{ name: 'artist', type: 'string' },
{ name: 'album', type: 'string' },
{ name: 'duration', type: 'int32' },
{ name: 'play_count', type: 'int32' },
{ name: 'genre', type: 'string[]', facet: true },
{ name: 'source_type', type: 'string' },
],
});
} catch (err: any) {
if (!err?.message?.includes('already exists')) throw err;
}
const tracksRes = await pgClient.query(
`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'`
);
const tracks = tracksRes.rows;
const genreMap = new Map<string, string[]>();
const genreRes = await pgClient.query(
`SELECT tg.track_id, g.name FROM track_genre tg JOIN genre g ON g.id = tg.genre_id`
);
for (const row of genreRes.rows) {
const arr = genreMap.get(row.track_id) || [];
arr.push(row.name);
genreMap.set(row.track_id, arr);
}
const collection = tsClient.collections('tracks').documents();
const batchSize = 100;
let indexed = 0;
for (let i = 0; i < tracks.length; i += batchSize) {
const batch = tracks.slice(i, i + batchSize);
const documents = batch.map((t: any) => ({
id: String(t.id),
title: t.title,
artist: t.artist,
album: t.album ?? '',
duration: Math.round(Number(t.duration)),
play_count: Number(t.play_count),
genre: genreMap.get(String(t.id)) || [],
source_type: t.source_type,
}));
await collection.import(documents, { action: 'upsert' });
indexed += documents.length;
}
console.log(`[Reindex] Indexed ${indexed} tracks into Typesense`);
break;
}
case 'reprocess_artists': {
const payload = job.data as ReprocessArtistsJob;
const batchSize = payload.batchSize ?? 100;
const offset = payload.offset ?? 0;
console.log(`[ReprocessArtists] Starting artist reprocessing (batch=${batchSize}, offset=${offset})`);
const artistsRes = await pgClient.query(
`SELECT id, name, canonical_name, mbid FROM artists ORDER BY id LIMIT $1 OFFSET $2`,
[batchSize, offset]
);
let processed = 0;
let updated = 0;
let merged = 0;
for (const artist of artistsRes.rows) {
processed++;
try {
const result = await enrichmentService.resolveArtistIdentity(artist.name);
// Merge if resolved to a different artist (duplicate detected)
if (result.artistId !== artist.id) {
merged++;
// Move track links to the keeper, skipping any (track, role) the
// keeper already has, then drop the loser's — a plain UPDATE would
// violate track_artists_pkey when both are on the same track.
await pgClient.query(
`INSERT INTO track_artists (track_id, artist_id, role)
SELECT track_id, $1, role FROM track_artists WHERE artist_id = $2
ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
[result.artistId, artist.id]
);
await pgClient.query(
`DELETE FROM track_artists WHERE artist_id = $1`,
[artist.id]
);
// Fold albums into the keeper. Move tracks of any same-title album
// to the keeper's matching album first (UNIQUE(artist_id,title) and
// ON DELETE CASCADE mean a blind UPDATE could collide or, worse,
// cascade-delete tracks when the loser artist is removed).
const dupAlbums = await pgClient.query(
`SELECT l.id AS loser_id, k.id AS keeper_id
FROM albums l JOIN albums k
ON k.artist_id = $1 AND lower(k.title) = lower(l.title)
WHERE l.artist_id = $2`,
[result.artistId, artist.id]
);
for (const { loser_id, keeper_id } of dupAlbums.rows) {
await pgClient.query(`UPDATE tracks SET album_id = $1 WHERE album_id = $2`, [keeper_id, loser_id]);
await pgClient.query(`DELETE FROM albums WHERE id = $1`, [loser_id]);
}
// Remaining (non-colliding) albums move over cleanly.
await pgClient.query(
`UPDATE albums SET artist_id = $1 WHERE artist_id = $2`,
[result.artistId, artist.id]
);
await pgClient.query(
`DELETE FROM artists WHERE id = $1`,
[artist.id]
);
console.log(`[ReprocessArtists] Merged "${artist.name}" (${artist.id}) -> "${result.canonicalName}" (${result.artistId})`);
} else {
// Update the existing artist record with new canonical_name and/or mbid
const updates: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (result.canonicalName && artist.canonical_name !== result.canonicalName) {
updates.push(`canonical_name = $${paramIdx++}`);
params.push(result.canonicalName);
}
if (result.mbid && artist.mbid !== result.mbid) {
updates.push(`mbid = $${paramIdx++}`);
params.push(result.mbid);
}
if (result.sortName && artist.sort_name !== result.sortName) {
updates.push(`sort_name = $${paramIdx++}`);
params.push(result.sortName);
}
if (updates.length > 0) {
updates.push(`updated_at = CURRENT_TIMESTAMP`);
params.push(artist.id);
await pgClient.query(
`UPDATE artists SET ${updates.join(', ')} WHERE id = $${paramIdx}`,
params
);
updated++;
console.log(`[ReprocessArtists] Updated "${artist.name}" (${artist.id}): ${updates.join(', ')}`);
}
}
// Refresh the artist image via the dedicated job rather than inline,
// so the reprocess batches aren't blocked on image HTTP. Deduped by
// jobId across the run.
await queue.add(
'artist_image',
{ artistId: result.artistId } satisfies ArtistImageJob,
{
jobId: `artist-image-${result.artistId}`,
removeOnComplete: { age: 86400, count: 5000 },
removeOnFail: { age: 86400, count: 5000 },
}
);
} catch (err) {
console.error(`[ReprocessArtists] Failed for artist ${artist.id} (${artist.name}):`, err);
}
}
console.log(`[ReprocessArtists] Batch complete: processed=${processed}, updated=${updated}, merged=${merged}`);
// If we processed a full batch, enqueue the next one
if (artistsRes.rows.length === batchSize) {
await queue.add('reprocess_artists', { batchSize, offset: offset + batchSize }, {
removeOnComplete: { age: 86400, count: 100 },
removeOnFail: { age: 86400, count: 100 },
});
console.log(`[ReprocessArtists] Enqueued next batch at offset ${offset + batchSize}`);
} else {
// Final batch - run deduplication pass to merge artists with same normalized_name
console.log(`[ReprocessArtists] All batches complete, running deduplication pass...`);
const dupRes = await pgClient.query(
`SELECT normalized_name, array_agg(id ORDER BY
CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
CASE WHEN canonical_name IS NOT NULL THEN 0 ELSE 1 END,
id
) as ids
FROM artists
GROUP BY normalized_name
HAVING COUNT(*) > 1`
);
let dedupMerged = 0;
for (const row of dupRes.rows) {
const ids = row.ids;
const keepId = ids[0]; // First one (prefers MBID, then canonical_name, then lowest id)
const mergeIds = ids.slice(1);
for (const mergeId of mergeIds) {
if (mergeId === keepId) continue;
try {
// First, handle track_artists conflicts: if both artists are on same track,
// keep the 'main' role, or merge roles. Use ON CONFLICT DO NOTHING to skip duplicates.
await pgClient.query(
`INSERT INTO track_artists (track_id, artist_id, role)
SELECT track_id, $1, role FROM track_artists WHERE artist_id = $2
ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
[keepId, mergeId]
);
// Then delete the old track_artists entries
await pgClient.query(
`DELETE FROM track_artists WHERE artist_id = $1`,
[mergeId]
);
// Fold same-title albums (move tracks) before reassigning the rest,
// to avoid UNIQUE(artist_id,title) collisions / cascade deletes.
const dupAlbums = await pgClient.query(
`SELECT l.id AS loser_id, k.id AS keeper_id
FROM albums l JOIN albums k
ON k.artist_id = $1 AND lower(k.title) = lower(l.title)
WHERE l.artist_id = $2`,
[keepId, mergeId]
);
for (const { loser_id, keeper_id } of dupAlbums.rows) {
await pgClient.query(`UPDATE tracks SET album_id = $1 WHERE album_id = $2`, [keeper_id, loser_id]);
await pgClient.query(`DELETE FROM albums WHERE id = $1`, [loser_id]);
}
await pgClient.query(
`UPDATE albums SET artist_id = $1 WHERE artist_id = $2`,
[keepId, mergeId]
);
await pgClient.query(
`DELETE FROM artists WHERE id = $1`,
[mergeId]
);
dedupMerged++;
console.log(`[ReprocessArtists] Dedup merged ${mergeId} -> ${keepId} (normalized: ${row.normalized_name})`);
} catch (err) {
console.error(`[ReprocessArtists] Dedup failed for ${mergeId}:`, err);
}
}
}
console.log(`[ReprocessArtists] Deduplication complete: merged=${dedupMerged}`);
// Album dedup pass: merge duplicate album rows (same title or same
// MBID) that accumulated before the albumartist scanner fix. Run
// after artist dedup so album artist_id refs are already resolved.
try {
const albumMerged = await enrichmentService.dedupAlbums();
console.log(`[ReprocessArtists] Album dedup: merged=${albumMerged}`);
} catch (err) {
console.error('[ReprocessArtists] Album dedup failed:', err);
}
console.log(`[ReprocessArtists] All artists processed!`);
}
break;
}
default:
console.log(`Received job of type: ${job.name} with data:`, job.data);
break;
}
}, { connection, concurrency: WORKER_CONCURRENCY });
worker.on('completed', (job) => {
console.log(`Job ${job.id} has completed!`);
});
worker.on('failed', (job, err) => {
console.log(`Job ${job?.id} has failed with error: ${err.message}`);
});
// Register the repeatable integrity sweep. bullmq's upsertJobScheduler is
// idempotent: keying on a fixed scheduler id ('integrity-sweep') means worker
// restarts update the existing schedule in place rather than stacking
// duplicate repeatable jobs.
await queue.upsertJobScheduler(
'integrity-sweep',
{ pattern: INTEGRITY_SWEEP_CRON },
{ name: 'integrity_sweep', data: { reason: 'scheduled' } }
);
console.log(`[Integrity] Sweep scheduled with cron: ${INTEGRITY_SWEEP_CRON}`);
await queue.upsertJobScheduler(
'cleanup-sweep',
{ pattern: CLEANUP_SWEEP_CRON },
{ name: 'cleanup_sweep', data: { reason: 'scheduled' } }
);
console.log(`[Cleanup] Sweep scheduled with cron: ${CLEANUP_SWEEP_CRON}`);
// Stale vibe-session reaper (Invariant B): hourly sweep that transitions
// ACTIVE batches idle for 24h to RESOLVED.
await queue.upsertJobScheduler(
'vibe-reap',
{ pattern: VIBE_REAP_CRON },
{ name: 'vibe_reap', data: { reason: 'scheduled' } }
);
console.log(`[VibeReap] Reaper scheduled with cron: ${VIBE_REAP_CRON}`);
// 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.
const startupDir = process.env.MUSIC_DIR || '/music';
const trackCount = await pgClient.query('SELECT COUNT(*)::int AS n FROM tracks');
if (trackCount.rows[0].n === 0) {
await queue.add('scan_library', { directory: startupDir }, {
removeOnComplete: { age: 86400, count: 100 },
removeOnFail: { age: 86400, count: 100 },
});
console.log(`[Startup] Enqueued library scan (fresh DB): ${startupDir}`);
} else {
console.log(`[Startup] Library has ${trackCount.rows[0].n} tracks — skipping startup scan`);
}
console.log('Worker is running and listening for jobs...');
// Graceful shutdown: close the worker, the scheduler queue and the pg client
// on termination signals. Guarded so a second signal is a no-op.
let shuttingDown = false;
const shutdown = async (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[Shutdown] Received ${signal}, shutting down gracefully...`);
try {
await worker.close();
await queue.close();
await pgClient.end();
console.log('[Shutdown] Clean shutdown complete.');
} catch (err) {
console.error('[Shutdown] Error during shutdown:', err);
}
process.exit(0);
};
process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
}
initWorker().catch(err => {
console.error('Failed to initialize worker:', err);
process.exit(1);
});
+112
View File
@@ -0,0 +1,112 @@
// Centralised, typed configuration for the external-integration clients.
//
// Enrichment is best-effort: a missing required key must NOT crash the worker.
// Instead the owning client logs a one-time warning at construction and its
// methods short-circuit to null/[] (see musicbrainz.client.ts / lastfm.client.ts).
export interface MusicBrainzConfig {
/** Base URL for the MusicBrainz web service v2. No trailing slash. */
baseUrl: string;
/**
* Contact (email or URL) embedded in the required User-Agent. MusicBrainz
* rejects/throttles requests without a meaningful UA. Empty if unset.
*/
contact: string;
/** Minimum interval between requests to the MB host (MB requires <=1 req/sec). */
minIntervalMs: number;
}
export interface LastFmConfig {
/** Base URL for the Last.fm 2.0 API. No trailing slash. */
baseUrl: string;
/** API key. Empty string when unconfigured -> client degrades to no-ops. */
apiKey: string;
/** Shared secret (only needed for authenticated/write calls; optional here). */
sharedSecret: string;
/** Minimum interval between requests to the Last.fm host. */
minIntervalMs: number;
}
export interface DiscogsConfig {
/** Base URL for the Discogs API. No trailing slash. */
baseUrl: string;
/** Personal access token. Empty string when unconfigured -> client degrades to no-ops. */
token: string;
/** Minimum interval between requests (Discogs allows ~60 req/min authenticated). */
minIntervalMs: number;
}
export interface LrcLibConfig {
/** Base URL for the LRCLib API. No trailing slash. */
baseUrl: string;
/** Minimum interval between requests (polite default; no key required). */
minIntervalMs: number;
}
export interface CoverArtConfig {
/** Base URL for the Cover Art Archive. No trailing slash. */
baseUrl: string;
/** Minimum interval between requests (polite default; no key required). */
minIntervalMs: number;
}
export interface WikimediaConfig {
/** Base URL for the Wikimedia API. No trailing slash. */
baseUrl: string;
/** Minimum interval between requests (polite default; no key required). */
minIntervalMs: number;
}
export interface IntegrationsConfig {
musicbrainz: MusicBrainzConfig;
lastfm: LastFmConfig;
discogs: DiscogsConfig;
lrclib: LrcLibConfig;
coverart: CoverArtConfig;
wikimedia: WikimediaConfig;
}
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, '');
}
function env(name: string, fallback = ''): string {
const v = process.env[name];
return v && v.trim() !== '' ? v.trim() : fallback;
}
export const integrationsConfig: IntegrationsConfig = {
musicbrainz: {
baseUrl: stripTrailingSlash(
env('MUSICBRAINZ_BASE_URL', 'https://musicbrainz.org/ws/2')
),
contact: env('MUSICBRAINZ_CONTACT'),
minIntervalMs: 1000,
},
lastfm: {
baseUrl: stripTrailingSlash(
env('LASTFM_BASE_URL', 'http://ws.audioscrobbler.com/2.0')
),
apiKey: env('LASTFM_API_KEY'),
sharedSecret: env('LASTFM_SHARED_SECRET'),
minIntervalMs: 250,
},
discogs: {
baseUrl: stripTrailingSlash(env('DISCOGS_BASE_URL', 'https://api.discogs.com')),
token: env('DISCOGS_TOKEN'),
// ~60 req/min authenticated -> ~1.1s between requests.
minIntervalMs: 1100,
},
lrclib: {
baseUrl: stripTrailingSlash(env('LRCLIB_BASE_URL', 'https://lrclib.net/api')),
minIntervalMs: 250,
},
coverart: {
baseUrl: stripTrailingSlash(env('COVERART_BASE_URL', 'https://coverartarchive.org')),
minIntervalMs: 250,
},
wikimedia: {
baseUrl: stripTrailingSlash(env('WIKIMEDIA_BASE_URL', 'https://commons.wikimedia.org')),
minIntervalMs: 250,
},
};
+102
View File
@@ -0,0 +1,102 @@
// Cover Art Archive client.
//
// No API key is required, so the client is always enabled. A polite per-host
// rate limit (~250ms) is enforced via the http base.
//
// /release/{mbid} returns JSON listing images[]; we pick the front image. CAA
// then redirects the image URL to archive.org, but we return the URL string
// directly (callers can fetch it; the http base follows redirects by default).
// A 404 (no cover art for that release) is treated as a clean miss (null).
//
// /artist/{mbid} returns artist images (same structure).
import { integrationsConfig, CoverArtConfig } from './config.js';
import { buildUserAgent, requestJson, HttpError } from './http.js';
// --- Raw CAA response shape (only the fields we read) ----------------------
interface CaaImage {
image?: string;
front?: boolean;
thumbnails?: { '500'?: string; large?: string; small?: string };
}
interface CaaReleaseResponse {
images?: CaaImage[];
}
export class CoverArtClient {
private readonly cfg: CoverArtConfig;
private readonly userAgent: string;
constructor(cfg: CoverArtConfig = integrationsConfig.coverart) {
this.cfg = cfg;
this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact);
}
private async get<T>(path: string): Promise<T> {
return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
userAgent: this.userAgent,
minIntervalMs: this.cfg.minIntervalMs,
});
}
/**
* Return the front cover URL for a release MBID (preferring the 500px
* thumbnail, falling back to the full image), or null on no art (404) / error.
*/
async getReleaseCoverUrl(releaseMbid: string): Promise<string | null> {
if (releaseMbid.trim() === '') return null;
const path = `/release/${encodeURIComponent(releaseMbid)}`;
try {
const data = await this.get<CaaReleaseResponse>(path);
const images = data.images ?? [];
const front = images.find((img) => img.front) ?? images[0];
if (!front) return null;
return front.thumbnails?.['500'] ?? front.image ?? null;
} catch (err) {
// 404 means no cover art for this release; treat as a clean miss.
if (err instanceof HttpError && err.status === 404) return null;
console.warn('[CoverArt] getReleaseCoverUrl failed:', (err as Error).message);
return null;
}
}
/**
* Return the front cover URL for a release-group MBID (preferring the 500px
* thumbnail, falling back to the full image), or null on no art (404) / error.
*
* This is the preferred CAA endpoint when a release-group MBID is available:
* it aggregates cover art across all releases in the group, so it hits more
* often than the per-release endpoint. Now that `albums.mbid` stores
* release-group MBIDs (from the MusicBrainz release-group search), this is a
* direct, exact lookup — no search step required.
*/
async getReleaseGroupCoverUrl(releaseGroupMbid: string): Promise<string | null> {
if (releaseGroupMbid.trim() === '') return null;
const path = `/release-group/${encodeURIComponent(releaseGroupMbid)}`;
try {
const data = await this.get<CaaReleaseResponse>(path);
const images = data.images ?? [];
const front = images.find((img) => img.front) ?? images[0];
if (!front) return null;
return front.thumbnails?.['500'] ?? front.image ?? null;
} catch (err) {
if (err instanceof HttpError && err.status === 404) return null;
console.warn('[CoverArt] getReleaseGroupCoverUrl failed:', (err as Error).message);
return null;
}
}
/**
* Cover Art Archive does NOT provide artist images, only release images.
* This method always returns null — kept for interface compatibility.
*/
async getArtistImageUrl(_artistMbid: string): Promise<string | null> {
return null;
}
}
+84
View File
@@ -0,0 +1,84 @@
// Deezer API client (read-only).
//
// Deezer provides a free, no-auth search API with high-quality cover art
// (up to 1000×1000 via the `cover_xl` field). Coverage is very good for
// European and mainstream releases.
//
// Rate limit: Deezer doesn't document a hard limit for the public search API,
// but ~50 req/min is a safe polite default, enforced via the http base.
//
// Best-effort: on any error, methods log a warning and return null rather than
// throwing, so enrichment never breaks the worker.
import { buildUserAgent, requestJson } from './http.js';
/** A normalised Deezer album search result (only the fields we read). */
export interface DeezerAlbum {
artistName: string;
title: string;
/** 1000×1000 cover URL. */
coverXl: string;
/** 500×500 cover URL (fallback). */
coverBig: string;
}
interface DeezerSearchResult {
artist?: { name?: string };
title?: string;
cover_xl?: string;
cover_big?: string;
}
interface DeezerSearchResponse {
data?: DeezerSearchResult[];
}
export class DeezerClient {
private readonly baseUrl = 'https://api.deezer.com';
private readonly userAgent: string;
private readonly minIntervalMs: number;
constructor(contact = '', minIntervalMs = 1200) {
this.userAgent = buildUserAgent(contact);
this.minIntervalMs = minIntervalMs;
}
/**
* Search the Deezer catalog for an album by artist + title and return the
* best match, or null if no match / error.
*
* Deezer's search `q` supports the fielded syntax `artist:"…" album:"…"`
* which gives precise matches.
*/
async searchAlbum(artist: string, album: string): Promise<DeezerAlbum | null> {
if (artist.trim() === '' || album.trim() === '') return null;
const q = `artist:"${artist}" album:"${album}"`;
const qs = new URLSearchParams({ q });
const url = `${this.baseUrl}/search/album?${qs.toString()}`;
try {
const data = await requestJson<DeezerSearchResponse>(url, {
userAgent: this.userAgent,
minIntervalMs: this.minIntervalMs,
});
const results = data.data ?? [];
if (results.length === 0) return null;
const best = results[0];
const coverXl = best.cover_xl ?? best.cover_big ?? null;
const coverBig = best.cover_big ?? best.cover_xl ?? null;
if (!coverXl) return null;
return {
artistName: best.artist?.name ?? artist,
title: best.title ?? album,
coverXl,
coverBig: coverBig ?? coverXl,
};
} catch (err) {
console.warn('[Deezer] searchAlbum failed:', (err as Error).message);
return null;
}
}
}
+130
View File
@@ -0,0 +1,130 @@
// Discogs API client (read-only search).
//
// Auth is via a personal access token sent as the
// `Authorization: Discogs token=<TOKEN>` header (set through the http base).
// Discogs allows ~60 requests/minute for authenticated clients, enforced via the
// http base's per-host rate limit (minIntervalMs ~1100ms).
//
// Best-effort: with no token configured, or on any error, methods log a warning
// and return null rather than throwing, so enrichment never breaks the worker.
import { integrationsConfig, DiscogsConfig } from './config.js';
import { buildUserAgent, requestJson } from './http.js';
/** A normalised Discogs release match. */
export interface DiscogsRelease {
discogsId: number;
year: number | null;
genres: string[];
styles: string[];
/** Cover image (full-size) URL, if any. */
coverImage: string | null;
/** Thumbnail URL, if any. */
thumb: string | null;
}
// --- Raw Discogs response shapes (only the fields we read) -----------------
interface DiscogsSearchResult {
id?: number;
year?: string | number;
genre?: string[];
style?: string[];
cover_image?: string;
thumb?: string;
}
interface DiscogsSearchResponse {
results?: DiscogsSearchResult[];
}
interface DiscogsArtistResponse {
images?: { uri?: string; uri150?: string; type?: string }[];
}
export class DiscogsClient {
private readonly cfg: DiscogsConfig;
private readonly userAgent: string;
private readonly enabled: boolean;
constructor(cfg: DiscogsConfig = integrationsConfig.discogs) {
this.cfg = cfg;
this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact);
this.enabled = cfg.token.trim() !== '';
if (!this.enabled) {
console.warn(
'[Discogs] DISCOGS_TOKEN not set; client disabled (methods return null).'
);
}
}
private async get<T>(path: string): Promise<T> {
return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
userAgent: this.userAgent,
minIntervalMs: this.cfg.minIntervalMs,
headers: { Authorization: `Discogs token=${this.cfg.token}` },
});
}
/**
* Search the database for the best release match for artist/album and return
* its id, year, genres/styles and cover image URLs, or null if no match (or
* the client is disabled / errors).
*/
async searchRelease(artist: string, album: string): Promise<DiscogsRelease | null> {
if (!this.enabled) return null;
if (artist.trim() === '' || album.trim() === '') return null;
const qs = new URLSearchParams({
type: 'release',
artist,
release_title: album,
});
const path = `/database/search?${qs.toString()}`;
try {
const data = await this.get<DiscogsSearchResponse>(path);
const best = (data.results ?? [])[0];
if (!best || typeof best.id !== 'number') return null;
const year =
best.year !== undefined && Number.isFinite(Number(best.year))
? Number(best.year)
: null;
return {
discogsId: best.id,
year,
genres: best.genre ?? [],
styles: best.style ?? [],
coverImage: best.cover_image ?? null,
thumb: best.thumb ?? null,
};
} catch (err) {
console.warn('[Discogs] searchRelease failed:', (err as Error).message);
return null;
}
}
/**
* Fetch artist images from Discogs using MusicBrainz ID for exact match.
* Does NOT fall back to name-based search (too error-prone for common names).
* Returns null if no MBID provided or no match found.
*/
async getArtistImageUrl(_artistName: string, artistMbid?: string): Promise<string | null> {
if (!this.enabled || !artistMbid?.trim()) return null;
try {
const mbPath = `/artists/${encodeURIComponent(artistMbid)}`;
const data = await this.get<DiscogsArtistResponse>(mbPath);
const images = data.images ?? [];
// Prefer primary image, then first available
const primary = images.find((img) => img.type === 'primary') ?? images[0];
return primary?.uri150 ?? primary?.uri ?? null;
} catch (err) {
console.warn('[Discogs] getArtistImageUrl failed:', (err as Error).message);
return null;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
// Fanart.tv client for artist images.
// API: https://fanart.tv/2015/03/api/
// Requires API key (free tier available).
import { requestJson } from './http.js';
interface FanartArtistResponse {
artistbackground?: Array<{ url: string }>;
artistthumb?: Array<{ url: string }>;
hdartistclearart?: Array<{ url: string }>;
artistlogo?: Array<{ url: string }>;
musicbanner?: Array<{ url: string }>;
}
export class FanartClient {
private readonly baseUrl = 'https://webservice.fanart.tv/v3/music';
private readonly apiKey: string;
private readonly userAgent = 'muzick/0.1';
constructor(apiKey?: string) {
this.apiKey = apiKey || process.env.FANART_API_KEY || '';
}
private get enabled(): boolean {
return this.apiKey.trim() !== '';
}
async getArtistImages(mbid: string): Promise<{
background?: string;
thumb?: string;
clearart?: string;
logo?: string;
banner?: string;
} | null> {
if (!this.enabled) return null;
if (!mbid) return null;
const url = `${this.baseUrl}/${encodeURIComponent(mbid)}?api_key=${encodeURIComponent(this.apiKey)}`;
try {
const data = await requestJson<FanartArtistResponse>(url, {
userAgent: this.userAgent,
minIntervalMs: 1000, // Be respectful
});
return {
background: data.artistbackground?.[0]?.url,
thumb: data.artistthumb?.[0]?.url,
clearart: data.hdartistclearart?.[0]?.url,
logo: data.artistlogo?.[0]?.url,
banner: data.musicbanner?.[0]?.url,
};
} catch (err) {
console.warn('[Fanart.tv] getArtistImages failed:', (err as Error).message);
}
return null;
}
/**
* Get the best available artist image.
* Priority: thumb > clearart > logo > background > banner
*/
async getBestArtistImage(mbid: string): Promise<string | null> {
const images = await this.getArtistImages(mbid);
if (!images) return null;
return images.thumb ?? images.clearart ?? images.logo ?? images.background ?? images.banner ?? null;
}
}
+270
View File
@@ -0,0 +1,270 @@
// HTTP base shared by every external-integration client.
//
// Provides:
// - configurable User-Agent (REQUIRED by MusicBrainz, format:
// `muzick/0.1 ( <contact> )`)
// - SOCKS5 proxy support via SOCKS_PROXY_URL env var
// (e.g. `socks5://127.0.0.1:10808`; no proxy when unset/empty)
// - exponential backoff with jitter on 429 / 5xx / network errors,
// honouring a `Retry-After` header when present
// - a simple per-host rate limit (min interval between requests to the same
// host) so e.g. MusicBrainz's <=1 req/sec rule is respected
// - typed JSON parsing via generics and a typed error on persistent failure.
//
// Why not global fetch? Node's built-in fetch doesn't support proxy agents.
// When SOCKS_PROXY_URL is set we build a proxy-aware fetcher via
// socks-proxy-agent + native https.request. Otherwise we use the global fetch
// (faster path, no overhead).
import * as http from 'http';
import * as https from 'https';
import { SocksProxyAgent } from 'socks-proxy-agent';
/** Error thrown when a request ultimately fails (non-2xx after all retries). */
export class HttpError extends Error {
constructor(
message: string,
readonly status: number,
readonly url: string,
readonly body?: string
) {
super(message);
this.name = 'HttpError';
}
}
// ── Proxy-aware fetch factory ────────────────────────────────────────────────
const SOCKS_PROXY_URL = process.env.SOCKS_PROXY_URL || '';
/**
* Return a `fetch`-compatible function that routes through the configured
* SOCKS5 proxy (if any), or the bare global fetch when no proxy is needed.
*/
function createFetch(): typeof fetch {
if (!SOCKS_PROXY_URL) return globalThis.fetch.bind(globalThis);
const agent = new SocksProxyAgent(SOCKS_PROXY_URL);
// Internal helper: issue a single request (no redirect following).
function requestOnce(
url: string,
method: string,
headers: Record<string, string> | undefined,
body: BodyInit | undefined,
signal: AbortSignal | undefined,
): Promise<Response> {
const urlObj = new URL(url);
const mod = urlObj.protocol === 'https:' ? https : http;
return new Promise<Response>((resolve, reject) => {
const req = mod.request(url, { agent, method, headers, signal }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
resolve(
new Response(Buffer.concat(chunks), {
status: res.statusCode,
statusText: res.statusMessage || '',
headers: new Headers(
Object.entries(res.headers).reduce(
(acc, [k, v]) => {
acc[k] = Array.isArray(v) ? v.join(', ') : String(v ?? '');
return acc;
},
{} as Record<string, string>,
)
),
})
);
});
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
const MAX_REDIRECTS = 5;
return async (input, init): Promise<Response> => {
let url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const method = init?.method || 'GET';
const headers = init?.headers as Record<string, string> | undefined;
const body = init?.body ?? undefined;
const signal = init?.signal ?? undefined;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
const res = await requestOnce(url, method, headers, body, signal);
const code = res.status;
// Follow redirect (301, 302, 307, 308) — max 5 hops.
if (code === 301 || code === 302 || code === 307 || code === 308) {
const location = res.headers.get('location');
if (!location) return res;
url = new URL(location, url).href;
continue;
}
return res;
}
// Too many redirects.
return new Response(null, { status: 508, statusText: 'Loop Detected' });
};
}
// Singleton fetcher — created once at module load.
const fetchFn = createFetch();
export interface RequestJsonOptions {
/** Full User-Agent header value. Required for MusicBrainz; recommended always. */
userAgent: string;
/** Minimum interval (ms) between requests to the same host. Default 1000. */
minIntervalMs?: number;
/** Max retry attempts on transient failures. Default 5. */
maxRetries?: number;
/** Base backoff delay (ms). Default 500. */
baseDelayMs?: number;
/** Cap on a single backoff delay (ms). Default 20000. */
maxDelayMs?: number;
/** Extra request headers (e.g. Accept). */
headers?: Record<string, string>;
/** Per-request timeout (ms). Default 15000. */
timeoutMs?: number;
}
// Per-host timestamp of the last request start, used to enforce the rate limit.
// Module-scoped so all clients sharing a host coordinate automatically.
const lastRequestAt = new Map<string, number>();
const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
/** Block until at least `minIntervalMs` has elapsed since this host's last request. */
async function throttle(host: string, minIntervalMs: number): Promise<void> {
const now = Date.now();
const last = lastRequestAt.get(host);
if (last !== undefined) {
const wait = minIntervalMs - (now - last);
if (wait > 0) await delay(wait);
}
lastRequestAt.set(host, Date.now());
}
/** Parse a Retry-After header (delta-seconds or HTTP date) into ms, or null. */
function parseRetryAfter(value: string | null): number | null {
if (!value) return null;
const secs = Number(value);
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
const when = Date.parse(value);
if (!Number.isNaN(when)) return Math.max(0, when - Date.now());
return null;
}
/** Exponential backoff with full jitter, capped at maxDelayMs. */
function backoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
const exp = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
return Math.floor(Math.random() * exp);
}
/**
* Fetch a URL and parse the JSON body as `T`. Retries transient failures with
* backoff + jitter; enforces the per-host rate limit. Throws `HttpError` on a
* persistent non-2xx response and rethrows the last network error if all
* attempts fail.
*/
export async function requestJson<T>(
url: string,
opts: RequestJsonOptions
): Promise<T> {
const {
userAgent,
minIntervalMs = 1000,
maxRetries = 5,
baseDelayMs = 500,
maxDelayMs = 20000,
headers = {},
timeoutMs = 15000,
} = opts;
const host = hostOf(url);
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
await throttle(host, minIntervalMs);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetchFn(url, {
headers: {
'User-Agent': userAgent,
Accept: 'application/json',
...headers,
},
signal: controller.signal,
});
// Retry on 429 (rate limited) and 5xx (transient server errors).
if (res.status === 429 || res.status >= 500) {
if (attempt < maxRetries) {
const retryAfter = parseRetryAfter(res.headers.get('retry-after'));
await delay(retryAfter ?? backoffDelay(attempt, baseDelayMs, maxDelayMs));
continue;
}
const body = await res.text().catch(() => undefined);
throw new HttpError(
`Request failed after ${maxRetries} retries: ${res.status} ${res.statusText}`,
res.status,
url,
body
);
}
if (!res.ok) {
const body = await res.text().catch(() => undefined);
throw new HttpError(
`Request failed: ${res.status} ${res.statusText}`,
res.status,
url,
body
);
}
return (await res.json()) as T;
} catch (err) {
// Non-retryable application errors propagate immediately.
if (err instanceof HttpError) throw err;
// Network / abort errors: retry with backoff, otherwise rethrow.
lastError = err;
if (attempt < maxRetries) {
await delay(backoffDelay(attempt, baseDelayMs, maxDelayMs));
continue;
}
throw err;
} finally {
clearTimeout(timer);
}
}
// Unreachable in practice; satisfies the type checker.
throw lastError instanceof Error
? lastError
: new Error('requestJson: exhausted retries');
}
/** Build the MusicBrainz-compliant User-Agent string. */
export function buildUserAgent(contact: string): string {
const c = contact && contact.trim() !== '' ? contact.trim() : 'no-contact-configured';
return `muzick/0.1 ( ${c} )`;
}
+46
View File
@@ -0,0 +1,46 @@
// Public surface of the external-integration client layer.
export { integrationsConfig } from './config.js';
export type {
IntegrationsConfig,
MusicBrainzConfig,
LastFmConfig,
DiscogsConfig,
LrcLibConfig,
CoverArtConfig,
WikimediaConfig,
} from './config.js';
export { requestJson, buildUserAgent, HttpError } from './http.js';
export type { RequestJsonOptions } from './http.js';
export { MusicBrainzClient } from './musicbrainz.client.js';
export type { RecordingMatch, RecordingDetail, RecordingCredit, ArtistTag as MusicBrainzArtistTag } from './musicbrainz.client.js';
export { LastFmClient } from './lastfm.client.js';
export type {
SimilarArtist,
SimilarTrack,
ArtistTag as LastFmArtistTag,
} from './lastfm.client.js';
export { DiscogsClient } from './discogs.client.js';
export type { DiscogsRelease } from './discogs.client.js';
export { LrcLibClient } from './lrclib.client.js';
export type { LyricsResult } from './lrclib.client.js';
export { CoverArtClient } from './coverart.client.js';
export { ITunesClient, upscaleITunesArtwork } from './itunes.client.js';
export type { ITunesAlbum } from './itunes.client.js';
export { DeezerClient } from './deezer.client.js';
export type { DeezerAlbum } from './deezer.client.js';
export { WikimediaClient } from './wikimedia.client.js';
export { WikidataClient } from './wikidata.client.js';
export { FanartClient } from './fanart.client.js';
export { TheAudioDbClient } from './theaudiodb.client.js';
+98
View File
@@ -0,0 +1,98 @@
// iTunes Search API client (read-only).
//
// The iTunes Search API is free, requires no API key, and has excellent
// coverage — nearly every released album is present. We use the album search
// to resolve cover art URLs. The default artwork URL returned is 100×100, but
// can be upgraded to 600×600 by swapping the size suffix in the URL string.
//
// Rate limit: Apple asks for "moderate" use (~20 req/min). We enforce a polite
// per-host interval via the http base.
//
// Best-effort: on any error, methods log a warning and return null rather than
// throwing, so enrichment never breaks the worker.
import { buildUserAgent, requestJson } from './http.js';
/** A normalised iTunes album search result (only the fields we read). */
export interface ITunesAlbum {
/** Artist canonical name. */
artistName: string;
/** Album / collection title. */
collectionName: string;
/** Cover art URL (100×100 — caller upscales). */
artworkUrl100: string;
}
interface ITunesSearchResult {
artistName?: string;
collectionName?: string;
artworkUrl100?: string;
}
interface ITunesSearchResponse {
results?: ITunesSearchResult[];
}
export class ITunesClient {
private readonly baseUrl = 'https://itunes.apple.com';
private readonly userAgent: string;
private readonly minIntervalMs: number;
constructor(contact = '', minIntervalMs = 3000) {
this.userAgent = buildUserAgent(contact);
this.minIntervalMs = minIntervalMs;
}
/**
* Search the iTunes catalog for an album by artist + title and return the
* best match, or null if no match / error. The returned `artworkUrl100` is
* the 100×100 thumbnail; callers upscale it to 600×600 via `upscaleArtwork`.
*/
async searchAlbum(artist: string, album: string): Promise<ITunesAlbum | null> {
if (artist.trim() === '' || album.trim() === '') return null;
// The iTunes search `term` is a free-text query; we quote both values to
// narrow the match. entity=album restricts to album collections.
const term = `${artist} ${album}`;
const qs = new URLSearchParams({ term, entity: 'album', limit: '5' });
const url = `${this.baseUrl}/search?${qs.toString()}`;
try {
const data = await requestJson<ITunesSearchResponse>(url, {
userAgent: this.userAgent,
minIntervalMs: this.minIntervalMs,
});
const results = data.results ?? [];
if (results.length === 0) return null;
// Prefer an exact title match (case-insensitive) to avoid grabbing a
// "Greatest Hits" when searching for a studio album, then fall back to
// the first result.
const lowerAlbum = album.toLowerCase();
const exact = results.find(
(r) => r.collectionName?.toLowerCase() === lowerAlbum
);
const best = exact ?? results[0];
if (!best.artworkUrl100) return null;
return {
artistName: best.artistName ?? artist,
collectionName: best.collectionName ?? album,
artworkUrl100: best.artworkUrl100,
};
} catch (err) {
console.warn('[iTunes] searchAlbum failed:', (err as Error).message);
return null;
}
}
}
/**
* Upscale an iTunes 100×100 artwork URL to a larger size. iTunes serves
* artwork from a CDN that supports arbitrary sizes via the `NNNxNNN` path
* segment (e.g. 600x600). Returns the input unchanged if it doesn't match the
* expected shape.
*/
export function upscaleITunesArtwork(url: string, size = 600): string {
return url.replace(/\/\d+x\d+(bb)?\.(jpg|png)$/, `/${size}x${size}bb.$2`);
}
+189
View File
@@ -0,0 +1,189 @@
// Last.fm 2.0 API client (read-only methods).
//
// Every call appends api_key + format=json and goes through the http base, which
// enforces a small per-host rate limit. Best-effort: with no API key configured,
// or on any error, methods return [] rather than throwing.
import { integrationsConfig, LastFmConfig } from './config.js';
import { buildUserAgent, requestJson } from './http.js';
export interface SimilarArtist {
name: string;
/** Similarity 0..1 as reported by Last.fm. */
match: number;
}
export interface ArtistTag {
name: string;
/** Normalised 0..1 weight (relative to the strongest tag). */
weight: number;
}
export interface SimilarTrack {
name: string;
artist: string;
/** Similarity 0..1 as reported by Last.fm. */
match: number;
}
// --- Raw Last.fm response shapes (only the fields we read) -----------------
interface LfmSimilarArtist {
name?: string;
match?: string | number;
}
interface LfmSimilarArtistsResponse {
similarartists?: { artist?: LfmSimilarArtist[] };
}
interface LfmTag {
name?: string;
count?: string | number;
}
interface LfmTopTagsResponse {
toptags?: { tag?: LfmTag[] };
}
interface LfmSimilarTrack {
name?: string;
match?: string | number;
artist?: { name?: string };
}
interface LfmSimilarTracksResponse {
similartracks?: { track?: LfmSimilarTrack[] };
}
interface LfmImage {
'#text'?: string;
size?: string;
}
interface LfmArtistInfoResponse {
artist?: { image?: LfmImage[] };
}
const toNum = (v: string | number | undefined): number => {
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : 0;
};
export class LastFmClient {
private readonly cfg: LastFmConfig;
private readonly userAgent: string;
private readonly enabled: boolean;
constructor(cfg: LastFmConfig = integrationsConfig.lastfm) {
this.cfg = cfg;
this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact);
this.enabled = cfg.apiKey.trim() !== '';
if (!this.enabled) {
console.warn(
'[Last.fm] LASTFM_API_KEY not set; client disabled (methods return []).'
);
}
}
/** Build a method URL with the supplied params plus api_key + format=json. */
private url(method: string, params: Record<string, string | number>): string {
const qs = new URLSearchParams({ method, api_key: this.cfg.apiKey, format: 'json' });
for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
return `${this.cfg.baseUrl}/?${qs.toString()}`;
}
private async get<T>(method: string, params: Record<string, string | number>): Promise<T> {
return requestJson<T>(this.url(method, params), {
userAgent: this.userAgent,
minIntervalMs: this.cfg.minIntervalMs,
});
}
/** artist.getsimilar -> up to `limit` similar artists with 0..1 match. */
async getSimilarArtists(artist: string, limit = 20): Promise<SimilarArtist[]> {
if (!this.enabled || artist.trim() === '') return [];
try {
const data = await this.get<LfmSimilarArtistsResponse>('artist.getsimilar', {
artist,
limit,
autocorrect: 1,
});
const list = data.similarartists?.artist ?? [];
return list
.filter((a): a is LfmSimilarArtist & { name: string } => !!a.name)
.map((a) => ({ name: a.name, match: toNum(a.match) }));
} catch (err) {
console.warn('[Last.fm] getSimilarArtists failed:', (err as Error).message);
return [];
}
}
/** artist.gettoptags -> tags with count normalised to 0..1. */
async getArtistTopTags(artist: string): Promise<ArtistTag[]> {
if (!this.enabled || artist.trim() === '') return [];
try {
const data = await this.get<LfmTopTagsResponse>('artist.gettoptags', {
artist,
autocorrect: 1,
});
const tags = (data.toptags?.tag ?? []).filter(
(t): t is LfmTag & { name: string } => !!t.name
);
if (tags.length === 0) return [];
const maxCount = Math.max(...tags.map((t) => toNum(t.count)), 1);
return tags
.map((t) => ({ name: t.name, weight: toNum(t.count) / maxCount }))
.sort((a, b) => b.weight - a.weight);
} catch (err) {
console.warn('[Last.fm] getArtistTopTags failed:', (err as Error).message);
return [];
}
}
/** artist.getinfo -> best available image URL, or null. */
async getArtistImageUrl(artist: string): Promise<string | null> {
if (!this.enabled || artist.trim() === '') return null;
try {
const data = await this.get<LfmArtistInfoResponse>('artist.getinfo', {
artist,
autocorrect: 1,
});
const images = data.artist?.image ?? [];
// Prefer 'extralarge', fall back through sizes in descending order.
for (const size of ['extralarge', 'large', 'medium', 'small']) {
const img = images.find((i) => i.size === size);
const url = img?.['#text']?.trim();
if (url && !url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return url;
}
return null;
} catch (err) {
console.warn('[Last.fm] getArtistImageUrl failed:', (err as Error).message);
return null;
}
}
/** track.getsimilar -> up to `limit` similar tracks with 0..1 match. */
async getSimilarTracks(
artist: string,
track: string,
limit = 20
): Promise<SimilarTrack[]> {
if (!this.enabled || artist.trim() === '' || track.trim() === '') return [];
try {
const data = await this.get<LfmSimilarTracksResponse>('track.getsimilar', {
artist,
track,
limit,
autocorrect: 1,
});
const list = data.similartracks?.track ?? [];
return list
.filter((t): t is LfmSimilarTrack & { name: string } => !!t.name)
.map((t) => ({
name: t.name,
artist: t.artist?.name ?? '',
match: toNum(t.match),
}));
} catch (err) {
console.warn('[Last.fm] getSimilarTracks failed:', (err as Error).message);
return [];
}
}
}
+76
View File
@@ -0,0 +1,76 @@
// LRCLib client (lyrics lookup).
//
// No API key is required, so the client is always enabled. A polite per-host
// rate limit (~250ms) is enforced via the http base.
//
// LRCLib's /get endpoint matches on track/artist/album/duration and returns 404
// when nothing matches; that 404 is treated as "no lyrics" (null), not an error.
// Any other failure also degrades to null so enrichment never breaks the worker.
import { integrationsConfig, LrcLibConfig } from './config.js';
import { buildUserAgent, requestJson, HttpError } from './http.js';
export interface LyricsResult {
/** Plain-text lyrics, if available. */
plainLyrics: string | null;
/** Time-synced lyrics as an LRC string, if available. */
syncedLyrics: string | null;
provider: 'lrclib';
}
// --- Raw LRCLib response shape (only the fields we read) -------------------
interface LrcLibGetResponse {
plainLyrics?: string | null;
syncedLyrics?: string | null;
}
export class LrcLibClient {
private readonly cfg: LrcLibConfig;
private readonly userAgent: string;
constructor(cfg: LrcLibConfig = integrationsConfig.lrclib) {
this.cfg = cfg;
this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact);
}
private async get<T>(path: string): Promise<T> {
return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
userAgent: this.userAgent,
minIntervalMs: this.cfg.minIntervalMs,
});
}
/**
* Look up lyrics for a track. Returns plain + synced lyrics, or null when no
* match (404) or on any error.
*/
async getLyrics(
artist: string,
title: string,
album?: string,
durationSec?: number
): Promise<LyricsResult | null> {
if (artist.trim() === '' || title.trim() === '') return null;
const qs = new URLSearchParams({ track_name: title, artist_name: artist });
if (album && album.trim() !== '') qs.set('album_name', album);
if (durationSec !== undefined && Number.isFinite(durationSec)) {
qs.set('duration', String(Math.round(durationSec)));
}
const path = `/get?${qs.toString()}`;
try {
const data = await this.get<LrcLibGetResponse>(path);
const plainLyrics = data.plainLyrics ?? null;
const syncedLyrics = data.syncedLyrics ?? null;
if (plainLyrics === null && syncedLyrics === null) return null;
return { plainLyrics, syncedLyrics, provider: 'lrclib' };
} catch (err) {
// 404 simply means no lyrics matched; treat as a clean miss.
if (err instanceof HttpError && err.status === 404) return null;
console.warn('[LRCLib] getLyrics failed:', (err as Error).message);
return null;
}
}
}
@@ -0,0 +1,519 @@
// MusicBrainz web-service v2 client.
//
// IMPORTANT MB requirements (https://musicbrainz.org/doc/MusicBrainz_API):
// - A meaningful User-Agent is REQUIRED (set via http base, see buildUserAgent).
// - Anonymous clients must not exceed 1 request/second. We enforce this through
// the http base's per-host rate limit (minIntervalMs = 1000 by default).
//
// All methods are best-effort: on missing contact config or any error they log a
// warning and return null / [] rather than throwing, so enrichment never breaks
// the worker.
import { integrationsConfig, MusicBrainzConfig } from './config.js';
import { buildUserAgent, requestJson } from './http.js';
/** Result of a recording lookup: the chosen recording plus linked entities. */
export interface RecordingMatch {
recordingMbid: string;
title: string;
/** Primary credited artist, if any. */
artistMbid: string | null;
artistName: string | null;
/** First associated release (album), if any. */
releaseMbid: string | null;
releaseTitle: string | null;
/** MB search score (0..100), surfaced for callers that want confidence. */
score: number;
}
/** Result of an artist search: the canonical identity for a name query. */
export interface ArtistMatch {
artistMbid: string;
/** MB canonical display name (e.g. "P!nk" not "Pink"). */
name: string;
/** MB sort-name (e.g. "Pink, P!" / "Beatles, The"). */
sortName: string | null;
/** MB search score (0..100). */
score: number;
/** Disambiguation comment when ambiguous (e.g. "US rock band"). */
disambiguation: string | null;
}
/** Result of a release-group search: the canonical album identity. */
export interface ReleaseGroupMatch {
releaseGroupMbid: string;
title: string;
/** Primary artist MBID on the release-group, if any. */
artistMbid: string | null;
artistName: string | null;
/** First release date as a 4-digit year, if available. */
year: number | null;
/** First release date as an ISO string (YYYY-MM-DD or YYYY-MM or YYYY), if available. */
firstReleaseDate: string | null;
/** MB search score (0..100). */
score: number;
}
/** A normalised genre/tag with a 0..1 weight. */
export interface ArtistTag {
name: string;
/** Normalised 0..1 weight (relative to the strongest tag for this artist). */
weight: number;
}
/** One entry in a MusicBrainz artist-credit array. */
export interface RecordingCredit {
/** The MB artist ID (UUID). */
artistMbid: string;
/** Canonical display name from the artist object. */
artistName: string;
/** Name as credited (may differ, e.g. "P!nk" vs "Pink"). */
creditName: string;
/** Joinphrase that follows this credit (" feat. ", " & ", etc.). */
joinphrase?: string;
}
/** Full recording fetched by MBID with inc=artist-credits. */
export interface RecordingDetail {
recordingMbid: string;
title: string;
artistCredit: RecordingCredit[];
}
/** Full release-group fetched by MBID with inc=artists. */
export interface ReleaseGroupDetail {
releaseGroupMbid: string;
title: string;
artistCredit: RecordingCredit[];
}
/** One artist-relation entry on an artist-rels lookup. */
export interface ArtistRelation {
/** MB relation type label, e.g. "member of", "is performance name of". */
type: string;
/** "forward" or "backward". */
direction: string;
/** The related artist's MBID. */
targetMbid: string;
/** The related artist's canonical display name. */
targetName: string;
}
// --- Raw MB response shapes (only the fields we read) ----------------------
interface MbArtistCredit {
artist?: { id?: string; name?: string };
name?: string;
}
interface MbReleaseRef {
id?: string;
title?: string;
}
interface MbRecording {
id: string;
title: string;
score?: number;
'artist-credit'?: MbArtistCredit[];
releases?: MbReleaseRef[];
}
interface MbRecordingDetail {
id: string;
title: string;
'artist-credit'?: Array<{
artist?: { id?: string; name?: string };
name?: string;
joinphrase?: string;
}>;
}
interface MbRecordingSearchResponse {
recordings?: MbRecording[];
}
interface MbReleaseGroupDetail {
id: string;
title?: string;
'artist-credit'?: Array<{
artist?: { id?: string; name?: string };
name?: string;
joinphrase?: string;
}>;
}
interface MbRelation {
type?: string;
direction?: string;
// MusicBrainz returns the related entity under a typed key (`artist` for
// artist-rels); recent MB versions may also surface a generic `target`
// string. We read `artist.id` / `artist.name` defensively.
artist?: { id?: string; name?: string };
target?: string;
}
interface MbRelationList {
'target-type'?: string;
relations?: MbRelation[];
}
interface MbArtistRelationsResponse {
id?: string;
name?: string;
// Modern MB: top-level `relations` array.
relations?: MbRelation[];
// Older MB: `relation-list` array, each grouping relations by target-type.
'relation-list'?: MbRelationList[];
}
interface MbTag {
name?: string;
count?: number;
}
interface MbGenre {
name?: string;
count?: number;
}
interface MbArtistResponse {
tags?: MbTag[];
genres?: MbGenre[];
}
interface MbArtistSearch {
id: string;
name?: string;
'sort-name'?: string;
score?: number;
disambiguation?: string;
}
interface MbArtistSearchResponse {
artists?: MbArtistSearch[];
}
interface MbReleaseGroupSearch {
id: string;
title?: string;
score?: number;
'first-release-date'?: string;
'artist-credit'?: MbArtistCredit[];
}
interface MbReleaseGroupSearchResponse {
'release-groups'?: MbReleaseGroupSearch[];
}
export class MusicBrainzClient {
private readonly cfg: MusicBrainzConfig;
private readonly userAgent: string;
private readonly enabled: boolean;
constructor(cfg: MusicBrainzConfig = integrationsConfig.musicbrainz) {
this.cfg = cfg;
this.userAgent = buildUserAgent(cfg.contact);
this.enabled = cfg.contact.trim() !== '';
if (!this.enabled) {
console.warn(
'[MusicBrainz] MUSICBRAINZ_CONTACT not set; client disabled (methods return null/[]).'
);
}
}
private async get<T>(path: string): Promise<T> {
return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
userAgent: this.userAgent,
minIntervalMs: this.cfg.minIntervalMs,
});
}
/**
* Search the recording endpoint for the best match for artist/title(/album)
* and return its MBID plus the linked artist + first release, or null if no
* match (or the client is disabled / errors).
*
* `minScore` (default 85): MB returns results pre-sorted by a 0..100 search
* score. Below this threshold the top hit is more often wrong than right, so
* we return null rather than poisoning identity with a bad MBID. Pass a lower
* value only for deliberately fuzzy matching.
*/
async lookupRecording(
artist: string,
title: string,
album?: string,
minScore = 85
): Promise<RecordingMatch | null> {
if (!this.enabled) return null;
// Build a fielded Lucene query; each value is quoted + escaped.
const esc = (v: string): string => v.replace(/(["\\])/g, '\\$1');
const terms = [`artist:"${esc(artist)}"`, `recording:"${esc(title)}"`];
if (album && album.trim() !== '') terms.push(`release:"${esc(album)}"`);
const query = terms.join(' AND ');
const path = `/recording?query=${encodeURIComponent(query)}&fmt=json&limit=5`;
try {
const data = await this.get<MbRecordingSearchResponse>(path);
const recordings = data.recordings ?? [];
if (recordings.length === 0) return null;
// MB returns results pre-sorted by score; take the top entry.
const best = recordings[0];
const score = typeof best.score === 'number' ? best.score : 0;
if (score < minScore) return null;
const credit = best['artist-credit']?.[0];
const release = best.releases?.[0];
return {
recordingMbid: best.id,
title: best.title,
artistMbid: credit?.artist?.id ?? null,
artistName: credit?.artist?.name ?? credit?.name ?? null,
releaseMbid: release?.id ?? null,
releaseTitle: release?.title ?? null,
score,
};
} catch (err) {
console.warn('[MusicBrainz] lookupRecording failed:', (err as Error).message);
return null;
}
}
/**
* Search the artist endpoint for the canonical identity matching a name.
* Returns the best match above `minScore` (default 85), or null. This is the
* correct endpoint for artist canonicalisation — unlike recording search it
* returns the authoritative `name` + `sort-name` directly.
*/
async searchArtist(name: string, minScore = 85): Promise<ArtistMatch | null> {
if (!this.enabled) return null;
if (name.trim() === '') return null;
const esc = (v: string): string => v.replace(/(["\\])/g, '\\$1');
const path = `/artist?query=${encodeURIComponent(`artist:"${esc(name)}"`)}&fmt=json&limit=5`;
try {
const data = await this.get<MbArtistSearchResponse>(path);
const artists = data.artists ?? [];
if (artists.length === 0) return null;
const best = artists[0];
const score = typeof best.score === 'number' ? best.score : 0;
if (score < minScore) return null;
return {
artistMbid: best.id,
name: best.name ?? name,
sortName: best['sort-name'] ?? null,
score,
disambiguation: best.disambiguation ?? null,
};
} catch (err) {
console.warn('[MusicBrainz] searchArtist failed:', (err as Error).message);
return null;
}
}
/**
* Search the release-group endpoint for the canonical album identity matching
* an artist + album title. Returns the best match above `minScore` (default
* 80 — album titles vary more than artist names, so a slightly looser
* threshold avoids missing legitimate matches), or null.
*
* A release-group is MB's canonical "album" entity: it groups all
* editions/releases of the same album under one stable MBID, making it the
* right value for `albums.mbid`.
*/
async searchReleaseGroup(
artist: string,
album: string,
minScore = 80
): Promise<ReleaseGroupMatch | null> {
if (!this.enabled) return null;
if (artist.trim() === '' || album.trim() === '') return null;
const esc = (v: string): string => v.replace(/(["\\])/g, '\\$1');
const query = `artist:"${esc(artist)}" AND release:"${esc(album)}"`;
const path = `/release-group?query=${encodeURIComponent(query)}&fmt=json&limit=5`;
try {
const data = await this.get<MbReleaseGroupSearchResponse>(path);
const groups = data['release-groups'] ?? [];
if (groups.length === 0) return null;
const best = groups[0];
const score = typeof best.score === 'number' ? best.score : 0;
if (score < minScore) return null;
const credit = best['artist-credit']?.[0];
const firstReleaseDate = best['first-release-date'] ?? null;
const year = firstReleaseDate
? parseInt(firstReleaseDate.slice(0, 4), 10)
: null;
return {
releaseGroupMbid: best.id,
title: best.title ?? album,
artistMbid: credit?.artist?.id ?? null,
artistName: credit?.artist?.name ?? credit?.name ?? null,
year: Number.isFinite(year) ? year : null,
firstReleaseDate,
score,
};
} catch (err) {
console.warn('[MusicBrainz] searchReleaseGroup failed:', (err as Error).message);
return null;
}
}
/**
* Fetch an artist's tags + genres and return them normalised to 0..1 weights
* (relative to the strongest tag). Returns [] if none / disabled / error.
*/
async getArtistTags(artistMbid: string): Promise<ArtistTag[]> {
if (!this.enabled) return [];
if (!artistMbid) return [];
const path = `/artist/${encodeURIComponent(artistMbid)}?inc=tags+genres&fmt=json`;
try {
const data = await this.get<MbArtistResponse>(path);
const raw = [...(data.genres ?? []), ...(data.tags ?? [])];
// Merge by name (genre + tag lists can overlap), keeping the max count.
const byName = new Map<string, number>();
for (const t of raw) {
const name = t.name?.trim();
if (!name) continue;
const count = typeof t.count === 'number' ? t.count : 0;
byName.set(name, Math.max(byName.get(name) ?? 0, count));
}
if (byName.size === 0) return [];
const maxCount = Math.max(...byName.values(), 1);
return [...byName.entries()]
.map(([name, count]) => ({ name, weight: count / maxCount }))
.sort((a, b) => b.weight - a.weight);
} catch (err) {
console.warn('[MusicBrainz] getArtistTags failed:', (err as Error).message);
return [];
}
}
/**
* Fetch a recording by its stable MBID with the full artist-credit array.
* Unlike lookupRecording (search), this is a direct ID lookup with no search
* step, making it the correct endpoint for spine-claim generation.
*/
async getRecording(recordingMbid: string): Promise<RecordingDetail | null> {
if (!this.enabled) return null;
if (!recordingMbid) return null;
const path = `/recording/${encodeURIComponent(recordingMbid)}?inc=artist-credits&fmt=json`;
try {
const data = await this.get<MbRecordingDetail>(path);
if (!data || !data.id) return null;
const credit = (data['artist-credit'] ?? [])
.map(c => ({
artistMbid: c.artist?.id ?? '',
artistName: c.artist?.name ?? c.name ?? '',
creditName: c.name ?? c.artist?.name ?? '',
joinphrase: c.joinphrase,
}))
.filter(c => c.artistMbid !== '');
return {
recordingMbid: data.id,
title: data.title,
artistCredit: credit,
};
} catch (err) {
console.warn('[MusicBrainz] getRecording failed:', (err as Error).message);
return null;
}
}
/**
* Fetch a release-group by its stable MBID with the full artist-credit
* array (`inc=artists`). Direct ID lookup — the analogous endpoint to
* getRecording() for album spine-claim generation.
*/
async getReleaseGroup(releaseGroupMbid: string): Promise<ReleaseGroupDetail | null> {
if (!this.enabled) return null;
if (!releaseGroupMbid) return null;
const path = `/release-group/${encodeURIComponent(releaseGroupMbid)}?inc=artists&fmt=json`;
try {
const data = await this.get<MbReleaseGroupDetail>(path);
if (!data || !data.id) return null;
const credit = (data['artist-credit'] ?? [])
.map(c => ({
artistMbid: c.artist?.id ?? '',
artistName: c.artist?.name ?? c.name ?? '',
creditName: c.name ?? c.artist?.name ?? '',
joinphrase: c.joinphrase,
}))
.filter(c => c.artistMbid !== '');
return {
releaseGroupMbid: data.id,
title: data.title ?? '',
artistCredit: credit,
};
} catch (err) {
console.warn('[MusicBrainz] getReleaseGroup failed:', (err as Error).message);
return null;
}
}
/**
* Fetch an artist's artist-relations (`inc=artist-rels`) and return the
* relation list normalised. Direct ID lookup — the source for `member_of`
* and `alias_of` claim generation.
*
* The MB web service exposes relations both as a modern top-level `relations`
* array and the legacy `relation-list` groupings keyed by `target-type`; we
* read both defensively. Only entries with a target artist MBID are kept.
*/
async getArtistRelations(artistMbid: string): Promise<ArtistRelation[]> {
if (!this.enabled) return [];
if (!artistMbid) return [];
const path = `/artist/${encodeURIComponent(artistMbid)}?inc=artist-rels&fmt=json`;
try {
const data = await this.get<MbArtistRelationsResponse>(path);
if (!data) return [];
const raw: MbRelation[] = [];
if (Array.isArray(data.relations)) {
raw.push(...data.relations);
}
for (const list of data['relation-list'] ?? []) {
if (list['target-type'] === 'artist' && Array.isArray(list.relations)) {
raw.push(...list.relations);
}
}
return raw
.map(r => ({
type: r.type ?? '',
direction: r.direction ?? 'forward',
targetMbid: r.artist?.id ?? r.target ?? '',
targetName: r.artist?.name ?? '',
}))
.filter(r => r.targetMbid !== '');
} catch (err) {
console.warn('[MusicBrainz] getArtistRelations failed:', (err as Error).message);
return [];
}
}
}
@@ -0,0 +1,94 @@
// TheAudioDB client for artist images.
// API: https://www.theaudiodb.com/api_guide.php
// Free tier: 2 requests/second, no key required for basic use.
// With API key: higher limits.
import { requestJson } from './http.js';
interface AudioDbArtist {
idArtist: string;
strArtist: string;
strArtistThumb?: string;
strArtistBanner?: string;
strArtistFanart?: string;
strArtistLogo?: string;
strArtistClearart?: string;
strArtistWideThumb?: string;
}
interface AudioDbResponse {
artists?: AudioDbArtist[];
}
export class TheAudioDbClient {
private readonly baseUrl = 'https://www.theaudiodb.com/api/v1/json';
private readonly apiKey: string;
private readonly userAgent = 'muzick/0.1';
constructor(apiKey?: string) {
// Free tier uses '1' as key, paid tiers get custom keys
this.apiKey = apiKey || process.env.THEAUDIO_DB_API_KEY || '1';
}
/**
* Search for artist by name.
*/
async searchArtist(name: string): Promise<AudioDbArtist | null> {
const url = `${this.baseUrl}/${this.apiKey}/search.php?s=${encodeURIComponent(name)}`;
try {
const data = await requestJson<AudioDbResponse>(url, {
userAgent: this.userAgent,
minIntervalMs: 500, // 2 req/sec limit
});
return data.artists?.[0] ?? null;
} catch (err) {
console.warn('[TheAudioDB] searchArtist failed:', (err as Error).message);
}
return null;
}
/**
* Get artist by MusicBrainz ID.
*/
async getArtistByMbid(mbid: string): Promise<AudioDbArtist | null> {
const url = `${this.baseUrl}/${this.apiKey}/artist-mb.php?i=${encodeURIComponent(mbid)}`;
try {
const data = await requestJson<AudioDbResponse>(url, {
userAgent: this.userAgent,
minIntervalMs: 500,
});
return data.artists?.[0] ?? null;
} catch (err) {
console.warn('[TheAudioDB] getArtistByMbid failed:', (err as Error).message);
}
return null;
}
/**
* Get the best available artist image.
* Priority: thumb > fanart > banner > logo > clearart > wide thumb
*/
async getBestArtistImage(mbid: string): Promise<string | null> {
// Try by MBID first (most accurate)
let artist = await this.getArtistByMbid(mbid);
// Fallback: search by name would need the name, which we don't have here
// Caller should handle name-based fallback
if (!artist) return null;
return (
artist.strArtistThumb ??
artist.strArtistFanart ??
artist.strArtistBanner ??
artist.strArtistLogo ??
artist.strArtistClearart ??
artist.strArtistWideThumb ??
null
);
}
}
+140
View File
@@ -0,0 +1,140 @@
// Wikidata client for artist images.
// Flow: MusicBrainz artist MBID → Wikidata ID (via MB) → Image (P18 property).
//
// Wikidata API: https://www.wikidata.org/w/api.php
// Property P18 = "image" (the main image for an entity).
import { requestJson } from './http.js';
interface WikidataEntity {
id: string;
labels?: Record<string, { value: string }>;
claims?: Record<string, Array<{
mainsnak?: {
datavalue?: { value: string };
};
}>>;
}
interface WikidataSearchResponse {
search?: Array<{
id: string;
label: string;
description: string;
}>;
}
interface WikidataEntitiesResponse {
entities?: Record<string, WikidataEntity>;
}
export class WikidataClient {
private readonly baseUrl = 'https://www.wikidata.org/w/api.php';
private readonly userAgent = 'muzick/0.1 (https://github.com/user/muzick)';
/**
* Get Wikidata ID for a MusicBrainz artist MBID.
* Queries Wikidata for entities with the MusicBrainz artist ID (P434).
*/
async getWikidataIdFromMbid(mbid: string): Promise<string | null> {
// Search Wikidata for entities with this MBID (P434 = MusicBrainz artist ID)
const url = `${this.baseUrl}?action=wbsearchentities&search=${encodeURIComponent(mbid)}&language=en&format=json&type=item&props=claims&sitefilter=musicbrainz`;
try {
const data = await requestJson<WikidataSearchResponse>(url, {
userAgent: this.userAgent,
minIntervalMs: 500, // Wikidata allows higher rate
});
const results = data.search ?? [];
if (results.length > 0) {
return results[0].id;
}
} catch (err) {
console.warn('[Wikidata] getWikidataIdFromMbid failed:', (err as Error).message);
}
return null;
}
/**
* Search Wikidata for an artist by name.
* Less reliable than MBID → Wikidata, but works as fallback.
*/
async searchArtistByName(name: string): Promise<string | null> {
const url = `${this.baseUrl}?action=wbsearchentities&search=${encodeURIComponent(name)}&language=en&format=json&type=item&limit=5`;
try {
const data = await requestJson<WikidataSearchResponse>(url, {
userAgent: this.userAgent,
minIntervalMs: 500,
});
const results = data.search ?? [];
// Filter for music artists (instance of: human, musical artist, band, etc.)
for (const result of results) {
if (this.looksLikeMusicArtist(result)) {
return result.id;
}
}
return results[0]?.id ?? null;
} catch (err) {
console.warn('[Wikidata] searchArtistByName failed:', (err as Error).message);
}
return null;
}
private looksLikeMusicArtist(result: { label: string; description: string }): boolean {
const desc = result.description.toLowerCase();
const musicKeywords = ['singer', 'musician', 'band', 'artist', 'rapper', 'producer', 'composer', 'dj', 'group'];
return musicKeywords.some(k => desc.includes(k));
}
/**
* Get the main image (P18) for a Wikidata entity.
* Returns the image filename (e.g., "Artist_Name.jpg") which can be used
* with Wikimedia Commons URL: https://commons.wikimedia.org/wiki/File:...
*/
async getImage(wikidataId: string): Promise<string | null> {
const url = `${this.baseUrl}?action=wbgetentities&ids=${encodeURIComponent(wikidataId)}&props=claims&format=json`;
try {
const data = await requestJson<WikidataEntitiesResponse>(url, {
userAgent: this.userAgent,
minIntervalMs: 500,
});
const entity = data.entities?.[wikidataId];
if (!entity?.claims?.P18) return null;
const imageClaim = entity.claims.P18[0];
const imageName = imageClaim?.mainsnak?.datavalue?.value;
if (!imageName) return null;
// Return Wikimedia Commons URL
// Format: https://commons.wikimedia.org/wiki/Special:FilePath/filename
const encoded = encodeURIComponent(imageName.replace(/ /g, '_'));
return `https://commons.wikimedia.org/wiki/Special:FilePath/${encoded}`;
} catch (err) {
console.warn('[Wikidata] getImage failed:', (err as Error).message);
}
return null;
}
/**
* Full pipeline: MBID → Wikidata ID → Image URL.
*/
async getArtistImageFromMbid(mbid: string): Promise<string | null> {
const wikidataId = await this.getWikidataIdFromMbid(mbid);
if (!wikidataId) return null;
return this.getImage(wikidataId);
}
/**
* Full pipeline fallback: name → Wikidata ID → Image URL.
*/
async getArtistImageFromName(name: string): Promise<string | null> {
const wikidataId = await this.searchArtistByName(name);
if (!wikidataId) return null;
return this.getImage(wikidataId);
}
}
@@ -0,0 +1,102 @@
// Wikimedia Commons client for artist images.
// Uses the MediaWiki API to search for and fetch artist images.
// No API key required; polite rate limit (~250ms) enforced via http base.
import { integrationsConfig, WikimediaConfig } from './config.js';
import { buildUserAgent, requestJson, HttpError } from './http.js';
interface WikimediaSearchResult {
title?: string;
pageid?: number;
thumbnail?: { source?: string; width?: number; height?: number };
}
interface WikimediaSearchResponse {
query?: {
search?: WikimediaSearchResult[];
};
}
interface WikimediaImageInfoResponse {
query?: {
pages?: {
[pageId: string]: {
imageinfo?: { url?: string; thumburl?: string; width?: number; height?: number }[];
};
};
};
}
export class WikimediaClient {
private readonly cfg: WikimediaConfig;
private readonly userAgent: string;
constructor(cfg: WikimediaConfig = integrationsConfig.wikimedia) {
this.cfg = cfg;
this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact);
}
private async get<T>(path: string): Promise<T> {
return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
userAgent: this.userAgent,
minIntervalMs: this.cfg.minIntervalMs,
});
}
/**
* Search Wikimedia Commons for an artist image and return the best available
* thumbnail URL (preferring larger sizes), or null on miss/error.
*/
async getArtistImageUrl(artistName: string): Promise<string | null> {
if (artistName.trim() === '') return null;
try {
// Search for the artist on Wikimedia Commons
const searchQs = new URLSearchParams({
action: 'query',
list: 'search',
srsearch: `${artistName} artist`,
srnamespace: '6', // File namespace
srlimit: '5',
format: 'json',
});
const searchPath = `/w/api.php?${searchQs.toString()}`;
const searchData = await this.get<WikimediaSearchResponse>(searchPath);
const results = searchData.query?.search ?? [];
if (results.length === 0) return null;
// Get image info for the first few results to find the best thumbnail
const pageIds = results.slice(0, 3).map((r) => r.pageid).filter((id): id is number => id !== undefined);
if (pageIds.length === 0) return null;
const imageQs = new URLSearchParams({
action: 'query',
prop: 'imageinfo',
iiprop: 'url|thumburl|width|height',
iiurlwidth: '300',
iiurlheight: '300',
pageids: pageIds.join('|'),
format: 'json',
});
const imagePath = `/w/api.php?${imageQs.toString()}`;
const imageData = await this.get<WikimediaImageInfoResponse>(imagePath);
const pages = imageData.query?.pages ?? {};
for (const pageId of pageIds) {
const page = pages[pageId.toString()];
if (page?.imageinfo?.[0]?.thumburl) {
return page.imageinfo[0].thumburl;
}
if (page?.imageinfo?.[0]?.url) {
return page.imageinfo[0].url;
}
}
return null;
} catch (err) {
if (err instanceof HttpError && err.status === 404) return null;
console.warn('[Wikimedia] getArtistImageUrl failed:', (err as Error).message);
return null;
}
}
}
+211
View File
@@ -0,0 +1,211 @@
import fs from 'fs/promises';
import { Client as PgClient } from 'pg';
import { Queue } from 'bullmq';
import { ScannerService } from './scanner.service.js';
const MUSIC_DIR = process.env.MUSIC_DIR || '/mnt/hdd1/media/Music';
// Detection regexes for the known (now-fixed) corruption bug. These are the
// exact patterns used by the original one-off repair script:
// title : trailing ' (Enriched)' (possibly stacked)
// artist : trailing 'Unknown Artist' (concatenated, no separator, possibly stacked)
// Anchored to end-of-string; '+' handles stacked corruption.
const TITLE_CORRUPT_RE = `( \\(Enriched\\))+\\s*$`;
const ARTIST_CORRUPT_RE = `\\s*(Unknown Artist)+\\s*$`;
interface CorruptRow {
id: string;
path: string;
title: string;
artist: string;
}
interface MissingRow {
id: string;
path: string;
title: string;
artist: string;
}
export interface SweepSummary {
detected: number;
fixed: number;
needsReview: number;
}
export class IntegrityService {
private scanner: ScannerService;
constructor(private pgClient: PgClient, private queue: Queue) {
this.scanner = new ScannerService(pgClient, queue);
}
/**
* Self-provision the issues table. Idempotent (CREATE TABLE IF NOT EXISTS),
* mirrors backend/src/db/schema.sql so the worker runs on older DBs.
*/
async ensureSchema(): Promise<void> {
await this.pgClient.query(
`CREATE TABLE IF NOT EXISTS track_integrity_issues (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
track_id UUID REFERENCES tracks(id) ON DELETE CASCADE,
issue_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'OPEN',
details TEXT,
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP,
UNIQUE(track_id, issue_type)
)`
);
}
/** Tracks whose title or artist still carry the corruption markers. */
async detectCorruptedMetadata(): Promise<CorruptRow[]> {
const res = await this.pgClient.query<CorruptRow>(
`SELECT id, path, title, artist
FROM tracks
WHERE title ~ $1 OR artist ~ $2`,
[TITLE_CORRUPT_RE, ARTIST_CORRUPT_RE]
);
return res.rows;
}
/** Tracks (excluding ones already flagged DELETED) whose file is gone from disk. */
async detectMissingFiles(limit = 5000): Promise<MissingRow[]> {
const res = await this.pgClient.query<MissingRow>(
`SELECT id, path, title, artist
FROM tracks
WHERE state <> 'DELETED'
LIMIT $1`,
[limit]
);
const missing: MissingRow[] = [];
for (const row of res.rows) {
try {
await fs.access(row.path);
} catch {
missing.push(row);
}
}
return missing;
}
/** Upsert an issue row keyed on (track_id, issue_type), refreshing on re-detection. */
private async upsertIssue(
trackId: string,
issueType: 'CORRUPT_METADATA' | 'MISSING_FILE',
status: 'OPEN' | 'FIXED' | 'NEEDS_REVIEW',
details: string | null
): Promise<void> {
await this.pgClient.query(
`INSERT INTO track_integrity_issues (track_id, issue_type, status, details, detected_at, resolved_at)
VALUES ($1, $2, $3, $4, NOW(), CASE WHEN $3 = 'FIXED' THEN NOW() ELSE NULL END)
ON CONFLICT (track_id, issue_type) DO UPDATE SET
status = EXCLUDED.status,
details = EXCLUDED.details,
detected_at = NOW(),
resolved_at = CASE WHEN EXCLUDED.status = 'FIXED' THEN NOW() ELSE NULL END`,
[trackId, issueType, status, details]
);
}
/**
* The defensive SQL strip (PASS 2 from the original repair script). Anchored,
* idempotent, only touches rows that actually match — a no-op on clean data.
*/
private async stripCorruptionMarkers(): Promise<void> {
await this.pgClient.query(
`UPDATE tracks
SET title = btrim(regexp_replace(title, '( \\(Enriched\\))+\\s*$', ''))
WHERE title ~ '( \\(Enriched\\))+\\s*$' OR title <> btrim(title)`
);
await this.pgClient.query(
`UPDATE tracks
SET artist = btrim(regexp_replace(artist, '\\s*(Unknown Artist)+\\s*$', ''))
WHERE artist ~ '\\s*(Unknown Artist)+\\s*$' OR artist <> btrim(artist)`
);
}
/**
* Orchestrate a full integrity sweep: detect, attempt auto-repair, then
* reconcile issue rows. Safe to run repeatedly; a clean DB is a no-op and
* produces no spurious issue rows.
*/
async runSweep(): Promise<SweepSummary> {
await this.ensureSchema();
const summary: SweepSummary = { detected: 0, fixed: 0, needsReview: 0 };
// --- Corrupted metadata --------------------------------------------------
const corrupt = await this.detectCorruptedMetadata();
summary.detected = corrupt.length;
// Record each corrupt row as an OPEN issue (only real matches get rows).
for (const row of corrupt) {
const details = `corrupt title="${row.title}" artist="${row.artist}"`;
await this.upsertIssue(row.id, 'CORRUPT_METADATA', 'OPEN', details);
}
if (corrupt.length > 0) {
// PASS 1: authoritative re-scan from disk overwrites title/artist by path.
console.log(`[Integrity] PASS 1: re-scanning library at ${MUSIC_DIR}`);
try {
await this.scanner.scanDirectory(MUSIC_DIR);
} catch (err) {
console.error('[Integrity] PASS 1 rescan failed (continuing to PASS 2):', err);
}
// PASS 2: defensive SQL strip for rows the scanner couldn't fix.
console.log('[Integrity] PASS 2: defensive SQL strip of corruption markers');
await this.stripCorruptionMarkers();
// Reconcile: re-check each previously-corrupt row individually.
const stillCorrupt = await this.pgClient.query<{ id: string }>(
`SELECT id FROM tracks
WHERE id = ANY($1::uuid[]) AND (title ~ $2 OR artist ~ $3)`,
[corrupt.map((r) => r.id), TITLE_CORRUPT_RE, ARTIST_CORRUPT_RE]
);
const stillCorruptIds = new Set(stillCorrupt.rows.map((r) => r.id));
for (const row of corrupt) {
if (stillCorruptIds.has(row.id)) {
// Could not auto-fix (e.g. file missing) -> flag for manual review.
await this.upsertIssue(
row.id,
'CORRUPT_METADATA',
'NEEDS_REVIEW',
`could not auto-repair; corrupt title="${row.title}" artist="${row.artist}"`
);
summary.needsReview++;
} else {
await this.upsertIssue(row.id, 'CORRUPT_METADATA', 'FIXED', null);
summary.fixed++;
}
}
}
// --- Missing files -------------------------------------------------------
const missing = await this.detectMissingFiles();
for (const row of missing) {
summary.detected++;
summary.needsReview++;
await this.upsertIssue(
row.id,
'MISSING_FILE',
'NEEDS_REVIEW',
`file not found on disk: ${row.path}`
);
// No-ghost rule: mark the track MISSING rather than deleting/keeping it as
// playable. Guarded UPDATE keeps this idempotent (only flips non-MISSING rows).
await this.pgClient.query(
`UPDATE tracks SET state = 'MISSING' WHERE id = $1 AND state <> 'MISSING'`,
[row.id]
);
}
console.log(
`[Integrity] Sweep summary: detected=${summary.detected} fixed=${summary.fixed} needsReview=${summary.needsReview}`
);
return summary;
}
}
+231
View File
@@ -0,0 +1,231 @@
import { Client as PgClient } from 'pg';
import { MusicBrainzClient } from './integrations/musicbrainz.client.js';
import { normalizeForMatching } from './utils/fuzzy-match.js';
function generateSortName(name: string): string {
const match = name.match(/^(The|A|An)\s+(.+)$/i);
if (match) return `${match[2]}, ${match[1]}`;
return name;
}
export class MbSpineWriter {
constructor(private pgClient: PgClient) {}
/**
* Fetch full artist-credit for a recording MBID and write claims.
* - First credit → "credited_main_on"
* - Additional credits → "featured_on"
* - Updates tracks.recording_mbid
* - Resolves or stubs credited artists by MBID
*/
async writeRecordingClaims(
recordingMbid: string,
trackId: string,
mbClient: MusicBrainzClient
): Promise<number> {
const recording = await mbClient.getRecording(recordingMbid);
if (!recording || recording.artistCredit.length === 0) return 0;
await this.pgClient.query(
`UPDATE tracks SET recording_mbid = $1 WHERE id = $2 AND recording_mbid IS DISTINCT FROM $1`,
[recordingMbid, trackId]
);
let claimsWritten = 0;
for (let i = 0; i < recording.artistCredit.length; i++) {
const credit = recording.artistCredit[i];
const artistId = await this.resolveArtist(
credit.artistMbid,
credit.artistName,
credit.creditName,
);
if (!artistId) {
console.warn(
`[MbSpineWriter] Could not resolve artist for credit ${i} on recording ${recordingMbid}`
);
continue;
}
const predicate = i === 0 ? 'credited_main_on' : 'featured_on';
const raw = credit.joinphrase ? JSON.stringify({ joinphrase: credit.joinphrase }) : null;
await this.pgClient.query(
`INSERT INTO claims
(subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
DO NOTHING`,
['track', trackId, predicate, 'artist', artistId, 'mb', 1.0, raw]
);
claimsWritten++;
}
return claimsWritten;
}
/**
* Fetch full artist-credit for a release-group MBID and write claims.
* - First credit → "credited_main_on_album"
* - Additional credits → "featured_on_album"
* - Resolves or stubs credited artists by MBID
*/
async writeAlbumClaims(
releaseGroupMbid: string,
albumId: string,
mbClient: MusicBrainzClient
): Promise<number> {
const releaseGroup = await mbClient.getReleaseGroup(releaseGroupMbid);
if (!releaseGroup || releaseGroup.artistCredit.length === 0) return 0;
let claimsWritten = 0;
for (let i = 0; i < releaseGroup.artistCredit.length; i++) {
const credit = releaseGroup.artistCredit[i];
const artistId = await this.resolveArtist(
credit.artistMbid,
credit.artistName,
credit.creditName,
);
if (!artistId) {
console.warn(
`[MbSpineWriter] Could not resolve artist for credit ${i} on release-group ${releaseGroupMbid}`
);
continue;
}
const predicate = i === 0 ? 'credited_main_on_album' : 'featured_on_album';
const raw = credit.joinphrase ? JSON.stringify({ joinphrase: credit.joinphrase }) : null;
await this.pgClient.query(
`INSERT INTO claims
(subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
DO NOTHING`,
['artist', artistId, predicate, 'album', albumId, 'mb', 1.0, raw]
);
claimsWritten++;
}
return claimsWritten;
}
/**
* Fetch an artist's MB artist-relations and write structural claims:
* - "member of" → "member_of" (subject = this artist, object = target group)
* - "is alias of" / "is performance name of" / "is legal name of" → "alias_of"
*
* Direction handling: MB marks the relationship as forward when it points from
* the queried artist to the target. For "is performance name of" (forward)
* the queried artist is the alias of the target, so subject = queried artist,
* object = target. For backward relations we flip subject/object.
*
* Collab/vocal/instrument ARs are artist-artist contributions rather than
* identity/structure, and the spec maps them to track-level featured_on; we
* skip them here to avoid over-claiming artist-artist edges.
*/
async writeArtistRelationClaims(
artistMbid: string,
artistId: string,
mbClient: MusicBrainzClient
): Promise<number> {
const relations = await mbClient.getArtistRelations(artistMbid);
if (relations.length === 0) return 0;
const memberOfTypes = new Set(['member of', 'founder of', 'founder']);
const aliasOfTypes = new Set(['is alias of', 'is performance name of', 'is legal name of']);
let claimsWritten = 0;
for (const rel of relations) {
const isMemberOf = [...memberOfTypes].some(t => rel.type.toLowerCase() === t);
const aliasMatch = [...aliasOfTypes].find(t => rel.type.toLowerCase() === t);
if (!isMemberOf && !aliasMatch) continue;
const targetArtistId = await this.resolveArtist(
rel.targetMbid,
rel.targetName,
rel.targetName,
);
if (!targetArtistId) {
console.warn(
`[MbSpineWriter] Could not resolve target artist for relation "${rel.type}" on artist ${artistMbid}`
);
continue;
}
// Forward direction: subject = this artist, object = target.
// Backward direction: the relation is phrased from target → subject, so
// we flip subject/object to preserve the predicate's intended direction.
let subjectId = artistId;
let objectId = targetArtistId;
if (rel.direction === 'backward') {
subjectId = targetArtistId;
objectId = artistId;
}
const predicate = aliasMatch ? 'alias_of' : 'member_of';
const raw = JSON.stringify({
mb_relation_type: rel.type,
direction: rel.direction,
});
await this.pgClient.query(
`INSERT INTO claims
(subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id)
DO NOTHING`,
['artist', subjectId, predicate, 'artist', objectId, 'mb', 1.0, raw]
);
claimsWritten++;
}
return claimsWritten;
}
/**
* Resolve an MB artist to a local artists.id. Priority:
* 1. Match by mbid (UUID column)
* 2. Match by normalized credit name
* 3. Create a stub row
*/
private async resolveArtist(
mbid: string,
artistName: string,
creditName: string,
): Promise<string | null> {
const existing = await this.pgClient.query<{ id: string }>(
`SELECT id FROM artists WHERE mbid = $1`,
[mbid]
);
if (existing.rows.length > 0) return existing.rows[0].id;
const normalized = normalizeForMatching(creditName);
const nameMatch = await this.pgClient.query<{ id: string }>(
`SELECT id FROM artists WHERE normalized_name = $1`,
[normalized]
);
if (nameMatch.rows.length > 0) {
await this.pgClient.query(
`UPDATE artists SET mbid = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
[mbid, nameMatch.rows[0].id]
);
return nameMatch.rows[0].id;
}
const sortName = generateSortName(artistName);
const result = await this.pgClient.query<{ id: string }>(
`INSERT INTO artists (name, canonical_name, sort_name, mbid)
VALUES ($1, $2, $3, $4)
ON CONFLICT (mbid) WHERE mbid IS NOT NULL
DO UPDATE SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP
RETURNING id`,
[creditName, artistName, sortName, mbid]
);
return result.rows[0]?.id ?? null;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { ConnectionOptions, Queue } from 'bullmq';
export const connection: ConnectionOptions = {
url: process.env.REDIS_URL || 'redis://localhost:6379',
};
export const QUEUE_NAME = 'muzick-queue';
// Shared Queue instance used to register repeatable/scheduled jobs (e.g. the
// periodic integrity sweep). Producers in other services can import this too.
export const queue = new Queue(QUEUE_NAME, { connection });
+293
View File
@@ -0,0 +1,293 @@
import fs from 'fs/promises';
import { createReadStream } from 'fs';
import { createHash } from 'crypto';
import path from 'path';
import mm from 'music-metadata';
import { Client as PgClient } from 'pg';
import { Queue } from 'bullmq';
import { MetadataRefreshJob, ArtistSimilarityJob, ArtistImageJob, AlbumCoverJob } from './types.js';
import { splitArtistNames, parseArtists } from './utils/artist-names.js';
/**
* Parse main + featured artists from music-metadata. Prefers the structured
* `artists[]` array when the tag provides it (each entry already one artist),
* otherwise falls back to splitting the single `artist` string. Feature markers
* in the title ("Song (feat. X)") are folded in either way. First artist is the
* main artist; the rest are featured. See ./utils/artist-names for the rules.
*/
function parseArtistsFromMetadata(common: any): { main: string; featured: string[] } {
const rawTitle = common.title || '';
// Structured array present: still split each entry (tags sometimes put a whole
// "A & B" collaboration in one slot) and fold in any title feature.
if (common.artists && Array.isArray(common.artists) && common.artists.length > 0) {
const fromArray = common.artists.flatMap((a: string) => splitArtistNames(a));
const fromTitleParse = parseArtists('', rawTitle);
const all: string[] = [];
const seen = new Set<string>();
for (const name of [...fromArray, ...fromTitleParse.featured]) {
const n = name.trim();
if (!n || seen.has(n.toLowerCase())) continue;
seen.add(n.toLowerCase());
all.push(n);
}
if (all.length > 0) {
return { main: all[0], featured: all.slice(1) };
}
}
return parseArtists(common.artist || 'Unknown Artist', rawTitle);
}
function hashFile(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = createHash('md5');
const stream = createReadStream(filePath);
stream.on('data', (d) => hash.update(d));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Resolve the album artist from metadata tags. The `albumartist` tag is the
* standard way to identify who "owns" the album as a whole — "Various Artists"
* for compilations, "Soundtrack" for OSTs, or the primary artist for normal
* albums. Without it, compilations where each track has a different artist
* would create duplicate album rows (one per track artist).
*
* Falls back to the track's main artist when the tag is absent (single-artist
* albums where the tag is redundant).
*
* Also splits the albumartist string via splitArtistNames so that "A & B" as
* albumartist uses just the first-billed artist as the album's identity —
* consistent with how normalize_artist treats track artists.
*/
function resolveAlbumArtist(common: any, fallback: string): string {
// music-metadata exposes albumartist as either a string or an array.
const raw = common.albumartist ?? common.albumArtist;
if (!raw) return fallback;
const str = Array.isArray(raw) ? raw[0] : String(raw);
if (str.trim() === '') return fallback;
const parts = splitArtistNames(str);
return parts.length > 0 ? parts[0] : fallback;
}
export class ScannerService {
// Per-scan dedupe sets for per-artist / per-album jobs. Belt-and-braces
// alongside the BullMQ jobId dedupe (jobId dedupes across overlapping scans /
// pending jobs; these avoid even issuing the redundant add() within one scan).
private enqueuedArtists = new Set<string>();
private enqueuedAlbums = new Set<string>();
constructor(private pgClient: PgClient, private queue: Queue) {}
async scanDirectory(directory: string) {
console.log(`[Scanner] Starting scan in: ${directory}`);
this.enqueuedArtists.clear();
this.enqueuedAlbums.clear();
await this.walk(directory);
console.log(`[Scanner] Scan completed.`);
}
private async walk(dir: 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);
} else if (this.isMusicFile(entry.name)) {
await this.processFile(fullPath);
}
}
}
private isMusicFile(fileName: string): boolean {
const extensions = ['.mp3', '.flac', '.m4a', '.wav', '.ogg'];
return extensions.includes(path.extname(fileName).toLowerCase());
}
private async processFile(filePath: string) {
try {
console.log(`[Scanner] Processing: ${filePath}`);
const metadata = await mm.parseFile(filePath);
const { common, format } = metadata;
// 1. Ensure Artist(s) exist.
const trackTitle = common.title || path.basename(filePath);
const { main: mainArtistRaw, featured: featuredArtistNames } = parseArtistsFromMetadata(common);
const { id: artistId, name: resolvedArtist } = await this.resolveOrCreateArtist(mainArtistRaw);
// Upsert featured artists into artists table and collect their ids.
const featuredIds: string[] = [];
for (const featName of featuredArtistNames) {
const feat = await this.resolveOrCreateArtist(featName);
featuredIds.push(feat.id);
}
// 2. Ensure Album exists.
// The album is keyed by (album_artist_id, title), NOT (track_artist_id,
// title). This prevents duplicate album rows for compilations where
// each track has a different artist but they all belong to one album.
// We resolve the ALBUM artist from the `albumartist` tag when present
// ("Various Artists", "Soundtrack", or the primary artist), falling
// back to the track's main artist when the tag is absent.
const albumTitle = common.album || 'Unknown Album';
const albumArtistRaw = resolveAlbumArtist(common, mainArtistRaw);
const albumArtistId = albumArtistRaw === mainArtistRaw
? artistId
: (await this.resolveOrCreateArtist(albumArtistRaw)).id;
const existingAlbum = await this.pgClient.query(
'SELECT id FROM albums WHERE artist_id = $1 AND lower(title) = lower($2) LIMIT 1',
[albumArtistId, albumTitle]
);
let albumId: string;
if (existingAlbum.rows.length > 0) {
albumId = String(existingAlbum.rows[0].id);
} else {
const albumRes = await this.pgClient.query(
'INSERT INTO albums (artist_id, title) VALUES ($1, $2) ON CONFLICT (artist_id, title) DO UPDATE SET title = EXCLUDED.title RETURNING id',
[albumArtistId, albumTitle]
);
albumId = String(albumRes.rows[0].id);
}
// 3. Upsert Track
const duration = format.duration || 0;
const fileHash = await hashFile(filePath);
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')
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())
RETURNING id
`,
[filePath, fileHash, trackTitle, resolvedArtist, albumId, duration]
);
const trackId = String(trackRes.rows[0].id);
// 4. Populate track_artists junction table.
await this.pgClient.query(
`INSERT INTO track_artists (track_id, artist_id, role)
VALUES ($1, $2, 'main')
ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
[trackId, artistId]
);
for (const featId of featuredIds) {
await this.pgClient.query(
`INSERT INTO track_artists (track_id, artist_id, role)
VALUES ($1, $2, 'featured')
ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
[trackId, featId]
);
}
console.log(`[Scanner] Successfully processed: ${trackTitle}`);
// 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));
} catch (err) {
console.error(`[Scanner] Error processing ${filePath}:`, err);
}
}
/**
* Resolve an artist by its case-insensitive normalized identity, creating the
* row only when no existing artist matches. This is what prevents case /
* punctuation variants ("Booker" vs "BOOKER", "Acryl madness" vs "Acryl
* Madness") from becoming separate rows (and duplicate albums). Returns the
* artist id and the stored display name.
*/
private async resolveOrCreateArtist(rawName: string): Promise<{ id: string; name: string }> {
const norm = await this.pgClient.query('SELECT normalize_artist($1) AS name', [rawName]);
let name: string = norm.rows[0].name;
// Comma heuristic: prefer the pre-comma part if it already exists.
if (name.includes(',')) {
const before = name.split(',')[0].trim();
const existing = await this.pgClient.query(
'SELECT id FROM artists WHERE lower(normalized_name) = lower(normalize_artist($1)) LIMIT 1',
[before]
);
if (existing.rows.length > 0) name = before;
}
// Case-insensitive lookup by normalized identity; prefer the most "complete"
// row when several somehow match.
const found = await this.pgClient.query(
`SELECT id, name FROM artists
WHERE lower(normalized_name) = lower(normalize_artist($1))
ORDER BY CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
CASE WHEN image_path IS NOT NULL AND image_path <> '' THEN 0 ELSE 1 END,
created_at
LIMIT 1`,
[name]
);
if (found.rows.length > 0) {
return { id: String(found.rows[0].id), name: String(found.rows[0].name) };
}
const inserted = await this.pgClient.query(
'INSERT INTO artists (name) VALUES ($1) RETURNING id, name',
[name]
);
return { id: String(inserted.rows[0].id), name: String(inserted.rows[0].name) };
}
private async enqueueEnrichment(trackId: string, artistId: string, albumId: string) {
const keep = {
removeOnComplete: { age: 86400, count: 5000 },
removeOnFail: { age: 86400, count: 5000 },
} as const;
// 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.
try {
const payload: MetadataRefreshJob = { trackId, refreshType: 'full' };
await this.queue.add('metadata_refresh', payload, { jobId: `meta-${trackId}`, ...keep });
} catch (err) {
console.error(`[Scanner] Failed to enqueue metadata_refresh for track ${trackId}:`, err);
}
// album_cover per album, deduped per scan (Set) + jobId across scans.
if (!this.enqueuedAlbums.has(albumId)) {
this.enqueuedAlbums.add(albumId);
try {
const payload: AlbumCoverJob = { albumId };
await this.queue.add('album_cover', payload, { jobId: `album-cover-${albumId}`, ...keep });
} catch (err) {
console.error(`[Scanner] Failed to enqueue album_cover for album ${albumId}:`, err);
}
}
// artist_similarity + artist_image per artist, deduped per scan (Set) AND
// across overlapping scans / pending jobs via their jobIds.
if (this.enqueuedArtists.has(artistId)) {
return;
}
this.enqueuedArtists.add(artistId);
try {
const payload: ArtistSimilarityJob = { artistId };
await this.queue.add('artist_similarity', payload, { jobId: `artist-sim-${artistId}`, ...keep });
} catch (err) {
console.error(`[Scanner] Failed to enqueue artist_similarity for artist ${artistId}:`, err);
}
try {
const payload: ArtistImageJob = { artistId };
await this.queue.add('artist_image', payload, { jobId: `artist-image-${artistId}`, ...keep });
} catch (err) {
console.error(`[Scanner] Failed to enqueue artist_image for artist ${artistId}:`, err);
}
}
}
+170
View File
@@ -0,0 +1,170 @@
/**
* One-off cleanup: case-insensitive dedupe of artists AND albums.
*
* normalize_artist() preserves case, so case/punctuation variants of the same
* artist ("Acryl madness" vs "Acryl Madness", "Booker" vs "BOOKER") were stored
* as distinct rows. Each variant carried its own album, producing duplicate —
* and often empty — albums (e.g. two "Before Neon 2", two "BROKESTAR").
*
* This:
* 1. Merges artists sharing lower(normalized_name) into one keeper (prefers a
* row with an MBID, then an image, then the most track links, then oldest).
* track_artists / albums / artist_similar / artist_aliases are moved over;
* losing artist rows are deleted.
* 2. Merges albums sharing (artist_id, lower(title)) into one keeper (prefers a
* row with artwork, then the most tracks, then oldest). Tracks are moved to
* the keeper BEFORE the empty duplicate is deleted (albums cascade-delete
* their tracks, so order matters).
* 3. Drops redundant 'featured' track_artists where the same artist is already
* 'main' on that track (case variants previously looked like a feature).
*
* Transactional + idempotent. Run:
* DATABASE_URL=... npx tsx src/scripts/dedup-artists-albums.ts [--dry-run]
*/
import { Client as PgClient } from 'pg';
const DRY_RUN = process.argv.includes('--dry-run');
async function main() {
const pg = new PgClient({ connectionString: process.env.DATABASE_URL });
await pg.connect();
console.log(`[Dedup] Connected${DRY_RUN ? ' (DRY RUN)' : ''}`);
if (!DRY_RUN) await pg.query('BEGIN');
let artistsMerged = 0;
let albumsMerged = 0;
// --- 1. Artist dedupe (case-insensitive) ---------------------------------
const artistGroups = await pg.query<{ ids: string[] }>(
`SELECT array_agg(id ORDER BY
CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
CASE WHEN image_path IS NOT NULL AND image_path <> '' THEN 0 ELSE 1 END,
(SELECT count(*) FROM track_artists ta WHERE ta.artist_id = artists.id) DESC,
created_at) AS ids
FROM artists
GROUP BY lower(normalized_name)
HAVING count(*) > 1`
);
for (const { ids } of artistGroups.rows) {
const keep = ids[0];
for (const loser of ids.slice(1)) {
if (DRY_RUN) { artistsMerged++; continue; }
// track_artists: copy to keeper, drop loser's.
await pg.query(
`INSERT INTO track_artists (track_id, artist_id, role)
SELECT track_id, $1, role FROM track_artists WHERE artist_id = $2
ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
[keep, loser]
);
await pg.query(`DELETE FROM track_artists WHERE artist_id = $1`, [loser]);
// artist_similar + aliases: move with conflict-skip.
await pg.query(
`INSERT INTO artist_similar (artist_id, similar_name, match, fetched_at)
SELECT $1, similar_name, match, fetched_at FROM artist_similar WHERE artist_id = $2
ON CONFLICT (artist_id, similar_name) DO NOTHING`,
[keep, loser]
);
await pg.query(`DELETE FROM artist_similar WHERE artist_id = $1`, [loser]);
await pg.query(
`INSERT INTO artist_aliases (artist_id, alias)
SELECT $1, alias FROM artist_aliases WHERE artist_id = $2
ON CONFLICT (artist_id, alias) DO NOTHING`,
[keep, loser]
);
await pg.query(`DELETE FROM artist_aliases WHERE artist_id = $1`, [loser]);
// Albums: move to keeper where no same-title album exists; otherwise fold
// tracks into the keeper's matching album, then drop the empty duplicate.
const loserAlbums = await pg.query<{ id: string; title: string }>(
`SELECT id, title FROM albums WHERE artist_id = $1`,
[loser]
);
for (const la of loserAlbums.rows) {
const match = await pg.query<{ id: string }>(
`SELECT id FROM albums WHERE artist_id = $1 AND lower(title) = lower($2) LIMIT 1`,
[keep, la.title]
);
if (match.rows.length === 0) {
await pg.query(`UPDATE albums SET artist_id = $1 WHERE id = $2`, [keep, la.id]);
} else {
await foldAlbum(pg, match.rows[0].id, la.id);
}
}
// Carry over an image if the keeper lacks one.
await pg.query(
`UPDATE artists k SET image_path = l.image_path
FROM artists l
WHERE k.id = $1 AND l.id = $2
AND (k.image_path IS NULL OR k.image_path = '')
AND l.image_path IS NOT NULL AND l.image_path <> ''`,
[keep, loser]
);
await pg.query(`DELETE FROM artists WHERE id = $1`, [loser]);
artistsMerged++;
}
}
// --- 2. Album dedupe (case-insensitive, within an artist) -----------------
const albumGroups = await pg.query<{ ids: string[] }>(
`SELECT array_agg(id ORDER BY
CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END,
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id) DESC,
id) AS ids
FROM albums
WHERE artist_id IS NOT NULL
GROUP BY artist_id, lower(title)
HAVING count(*) > 1`
);
for (const { ids } of albumGroups.rows) {
const keep = ids[0];
for (const loser of ids.slice(1)) {
if (DRY_RUN) { albumsMerged++; continue; }
await foldAlbum(pg, keep, loser);
albumsMerged++;
}
}
// --- 3. Drop redundant 'featured' where the artist is also 'main' ---------
let redundant = 0;
if (!DRY_RUN) {
const r = await pg.query(
`DELETE FROM track_artists f
WHERE f.role = 'featured'
AND EXISTS (SELECT 1 FROM track_artists m
WHERE m.track_id = f.track_id AND m.artist_id = f.artist_id AND m.role = 'main')`
);
redundant = r.rowCount ?? 0;
}
if (!DRY_RUN) await pg.query('COMMIT');
console.log(`[Dedup] Done: artistsMerged=${artistsMerged} albumsMerged=${albumsMerged} redundantFeatured=${redundant}`);
await pg.end();
}
/** Move all tracks + (missing) artwork from loserAlbum into keeperAlbum, then delete loserAlbum. */
async function foldAlbum(pg: PgClient, keeperId: string, loserId: string): Promise<void> {
if (keeperId === loserId) return;
await pg.query(`UPDATE tracks SET album_id = $1 WHERE album_id = $2`, [keeperId, loserId]);
await pg.query(
`UPDATE albums k SET artwork_id = l.artwork_id, year = COALESCE(k.year, l.year)
FROM albums l
WHERE k.id = $1 AND l.id = $2
AND (k.artwork_id IS NULL OR k.artwork_id = '')
AND l.artwork_id IS NOT NULL AND l.artwork_id <> ''`,
[keeperId, loserId]
);
await pg.query(`DELETE FROM albums WHERE id = $1`, [loserId]);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error('[Dedup] Failed:', err);
process.exit(1);
});
@@ -0,0 +1,45 @@
/**
* One-off cleanup: repair track rows corrupted by an earlier buggy
* `metadata_refresh` worker job.
*
* The bug ran on EVERY refresh and concatenated (no separator) corruption
* markers directly onto the real values:
* - tracks.title += ' (Enriched)'
* - tracks.artist += 'Unknown Artist'
* Because it ran repeatedly, corruption can be stacked, e.g.
* title = "Song (Enriched) (Enriched)"
* artist = "RealName Unknown ArtistUnknown Artist"
*
* The repair logic (PASS 1 authoritative rescan + PASS 2 defensive SQL strip,
* plus issue tracking) now lives in IntegrityService.runSweep(). This script is
* just a run-once-and-exit entry point that delegates to it, so the regex/rescan
* logic is never duplicated. Safe to run multiple times (idempotent).
*/
import { Client as PgClient } from 'pg';
import { IntegrityService } from '../integrity.service.js';
import { queue } from '../queue.js';
async function main() {
const pgClient = new PgClient({
connectionString: process.env.DATABASE_URL,
});
await pgClient.connect();
console.log('[Repair] Connected to PostgreSQL');
const integrity = new IntegrityService(pgClient, queue);
const summary = await integrity.runSweep();
console.log(
`[Repair] Done: detected=${summary.detected} fixed=${summary.fixed} needsReview=${summary.needsReview}`
);
await pgClient.end();
console.log('[Repair] Connection closed.');
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error('[Repair] Failed:', err);
process.exit(1);
});
+204
View File
@@ -0,0 +1,204 @@
/**
* One-off cleanup: split combined collaboration artist rows into the real
* individual artists they name, and dedupe the result.
*
* Background: tags routinely store several artists in one string
* ("Booker & ЗАМАЙ", "STED.D; Alphavite", "21 Savage & Metro Boomin"). An
* earlier normalize_artist only handled commas + feat suffixes, so these were
* persisted as a single bogus artist row — duplicating the real artists (which
* also exist on their own) and producing two-artists-in-one entries.
*
* What this does, per combined artist row C (name splits into >1 artist):
* 1. Resolve/create a canonical artist row for each component, keyed by
* normalize_artist() so "Booker & X" folds into the existing "Booker".
* 2. For every track linked to C (via track_artists), link it to all the real
* components instead: first = 'main', the rest = 'featured'.
* 3. Reassign C's albums to its primary component.
* 4. Delete C (cascades its track_artists / artist_similar / aliases).
* Then a final pass merges any artists that share a normalized_name (preferring
* the row with an MBID, then a canonical_name).
*
* Idempotent: re-running after a clean run is a no-op (no row will split into
* more than one component once the data is fixed). Wrapped in a transaction —
* either the whole cleanup commits or nothing does.
*
* Run: DATABASE_URL=... npx tsx src/scripts/split-collab-artists.ts
* (or, built) node dist/scripts/split-collab-artists.js
* Add --dry-run to print what would change without writing.
*/
import { Client as PgClient } from 'pg';
import { splitArtistNames } from '../utils/artist-names.js';
interface ArtistRow {
id: string;
name: string;
mbid: string | null;
canonical_name: string | null;
}
const DRY_RUN = process.argv.includes('--dry-run');
async function main() {
const pg = new PgClient({ connectionString: process.env.DATABASE_URL });
await pg.connect();
console.log(`[SplitCollab] Connected${DRY_RUN ? ' (DRY RUN — no writes)' : ''}`);
const { rows: artists } = await pg.query<ArtistRow>(
'SELECT id, name, mbid, canonical_name FROM artists'
);
// Identify the combined rows: name names more than one artist.
const combined = artists.filter((a) => splitArtistNames(a.name).length > 1);
console.log(`[SplitCollab] ${artists.length} artists total, ${combined.length} combined rows to split`);
if (!DRY_RUN) await pg.query('BEGIN');
let tracksRelinked = 0;
let createdArtists = 0;
/**
* Resolve a single artist name to a canonical artist id, by normalized_name.
* Creates the row if no match exists. Returns the id.
*/
async function resolveArtistId(name: string): Promise<string> {
const existing = await pg.query(
`SELECT id FROM artists
WHERE normalized_name = normalize_artist($1)
ORDER BY CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
CASE WHEN canonical_name IS NOT NULL THEN 0 ELSE 1 END,
created_at
LIMIT 1`,
[name]
);
if (existing.rows.length > 0) return existing.rows[0].id;
if (DRY_RUN) {
console.log(`[SplitCollab] would create artist "${name}"`);
return `dry-${name}`;
}
const inserted = await pg.query(
`INSERT INTO artists (name, canonical_name, sort_name)
VALUES ($1, $1, $1) RETURNING id`,
[name]
);
createdArtists++;
return inserted.rows[0].id;
}
for (const c of combined) {
const components = splitArtistNames(c.name);
console.log(`[SplitCollab] "${c.name}" -> [${components.join(' | ')}]`);
const componentIds: string[] = [];
for (const name of components) componentIds.push(await resolveArtistId(name));
const primaryId = componentIds[0];
if (DRY_RUN) continue;
// Tracks currently attributed to the combined row.
const { rows: links } = await pg.query<{ track_id: string }>(
'SELECT DISTINCT track_id FROM track_artists WHERE artist_id = $1',
[c.id]
);
for (const { track_id } of links) {
// primary = main, the rest = featured. Skip self-conflicts.
await pg.query(
`INSERT INTO track_artists (track_id, artist_id, role)
VALUES ($1, $2, 'main') ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
[track_id, primaryId]
);
for (const featId of componentIds.slice(1)) {
if (featId === primaryId) continue;
await pg.query(
`INSERT INTO track_artists (track_id, artist_id, role)
VALUES ($1, $2, 'featured') ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
[track_id, featId]
);
}
tracksRelinked++;
}
// Reassign the combined row's albums to the primary artist, but only where
// that wouldn't collide with an existing (primary, title) album. Colliding
// albums are dropped (the primary already has that album).
await pg.query(
`UPDATE albums a SET artist_id = $1
WHERE a.artist_id = $2
AND NOT EXISTS (
SELECT 1 FROM albums b WHERE b.artist_id = $1 AND b.title = a.title
)`,
[primaryId, c.id]
);
// Drop the combined artist (cascades remaining track_artists/aliases/similar
// and any colliding albums via ON DELETE CASCADE).
await pg.query('DELETE FROM artists WHERE id = $1', [c.id]);
}
// Final dedupe: collapse any artists sharing a normalized_name into one,
// preferring the row with an MBID, then a canonical_name, then oldest.
let dedupMerged = 0;
if (!DRY_RUN) {
const { rows: dups } = await pg.query<{ ids: string[] }>(
`SELECT array_agg(id ORDER BY
CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END,
CASE WHEN canonical_name IS NOT NULL THEN 0 ELSE 1 END,
created_at) AS ids
FROM artists
GROUP BY normalized_name
HAVING COUNT(*) > 1`
);
for (const { ids } of dups) {
const keepId = ids[0];
for (const mergeId of ids.slice(1)) {
await pg.query(
`INSERT INTO track_artists (track_id, artist_id, role)
SELECT track_id, $1, role FROM track_artists WHERE artist_id = $2
ON CONFLICT (track_id, artist_id, role) DO NOTHING`,
[keepId, mergeId]
);
await pg.query('DELETE FROM track_artists WHERE artist_id = $1', [mergeId]);
await pg.query(
`UPDATE albums a SET artist_id = $1
WHERE a.artist_id = $2
AND NOT EXISTS (SELECT 1 FROM albums b WHERE b.artist_id = $1 AND b.title = a.title)`,
[keepId, mergeId]
);
await pg.query('DELETE FROM artists WHERE id = $1', [mergeId]);
dedupMerged++;
}
}
}
// Drop redundant 'featured' links where the same artist is already 'main' on
// that track (can happen when a stale pre-split 'main' link coexists with a
// 'featured' link added from a collaboration row).
let redundantRemoved = 0;
if (!DRY_RUN) {
const res = await pg.query(
`DELETE FROM track_artists f
WHERE f.role = 'featured'
AND EXISTS (
SELECT 1 FROM track_artists m
WHERE m.track_id = f.track_id AND m.artist_id = f.artist_id AND m.role = 'main'
)`
);
redundantRemoved = res.rowCount ?? 0;
}
if (!DRY_RUN) await pg.query('COMMIT');
console.log(
`[SplitCollab] Done: split=${combined.length} relinkedTracks=${tracksRelinked} ` +
`createdArtists=${createdArtists} dedupMerged=${dedupMerged} redundantFeaturedRemoved=${redundantRemoved}`
);
await pg.end();
}
main()
.then(() => process.exit(0))
.catch(async (err) => {
console.error('[SplitCollab] Failed:', err);
process.exit(1);
});
+60
View File
@@ -0,0 +1,60 @@
export interface MetadataRefreshJob {
trackId: string;
refreshType: 'full' | 'partial';
}
export interface AudioAnalysisJob {
trackId: string;
features: string[];
}
export interface CleanupJob {
reason: 'expired' | 'manual';
targetFiles: string[];
}
export interface LibraryScanJob {
directory: string;
}
// Periodic integrity sweep. No payload fields required; the worker reads
// MUSIC_DIR from env. Kept as an object for forward-compatibility.
export interface IntegritySweepJob {
// Reserved for future scoping options (e.g. specific track ids).
reason?: 'scheduled' | 'manual';
}
// Refresh Last.fm similar-artist data for a single artist (feeds Vibe discovery).
export interface ArtistSimilarityJob {
artistId: string;
}
// Fetch/refresh a single artist's image via the fallback chain. Decoupled from
// track enrichment so image lookups don't run inline while processing tracks.
export interface ArtistImageJob {
artistId: string;
}
// Fetch/refresh a single album's cover art (Discogs + Cover Art Archive).
// Decoupled from track enrichment for the same reason.
export interface AlbumCoverJob {
albumId: string;
}
// Re-process all artists through the new identity pipeline.
// Useful after schema migrations or when identity logic changes.
export interface ReprocessArtistsJob {
batchSize?: number;
offset?: number;
}
export type JobPayload =
| MetadataRefreshJob
| ArtistSimilarityJob
| ArtistImageJob
| AlbumCoverJob
| AudioAnalysisJob
| CleanupJob
| LibraryScanJob
| IntegritySweepJob
| ReprocessArtistsJob;
+82
View File
@@ -0,0 +1,82 @@
/**
* Shared artist-name parsing.
*
* Music tags routinely cram several artists into one string using a variety of
* separators ("A & B", "A; B", "A, B", "A / B", "A x B") and feature markers
* ("A feat. B", "A ft. B", "Song (feat. B)"). Historically these were stored as
* a single combined artist row (e.g. "Booker & ЗАМАЙ"), which both duplicated
* the real artists and produced bogus two-artists-in-one entries.
*
* This module is the single source of truth for breaking such a string into the
* individual artists it actually names. The FIRST artist is treated as the main
* artist; the rest are collaborators/features. Used by the scanner (at ingest)
* and by the split-collab-artists migration (to clean existing rows).
*/
// Collaboration separators that join co-billed artists: ; / & , and the
// space-delimited word "x" ("Баста x Capella"). NOTE: matched case-insensitively
// and only as a standalone token so it doesn't chop names that merely contain x.
const SEPARATOR_RE = /\s*(?:;|\/|&|,)\s*|\s+x\s+/i;
// Feature/versus markers. Everything after the marker is a featured artist (or a
// list of them, which is split again on SEPARATOR_RE by the caller).
const FEAT_SPLIT_RE = /\s+(?:feat(?:uring)?\.?|ft\.?|vs\.?)\s+/i;
// Parenthesized feature marker inside a track title: "Song (feat. Guest)".
const TITLE_FEAT_RE = /[\(\[]\s*(?:feat(?:uring)?\.?|ft\.?|vs\.?)\s+([^\)\]]+)[\)\]]/i;
/** Case-insensitive, order-preserving dedupe. */
function dedupe(names: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of names) {
const n = raw.trim();
if (!n) continue;
const key = n.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(n);
}
return out;
}
/**
* Split a raw artist string into the individual artists it names, in billing
* order. Handles feature markers and all collaboration separators. Returns a
* deduped list; a plain single artist yields a one-element array.
*/
export function splitArtistNames(rawArtist: string): string[] {
if (!rawArtist) return [];
// Feature markers first: "A feat. B & C" -> ["A", "B & C"], then each chunk is
// split on collaboration separators.
const featChunks = rawArtist.split(FEAT_SPLIT_RE);
const parts: string[] = [];
for (const chunk of featChunks) {
parts.push(...chunk.split(SEPARATOR_RE));
}
return dedupe(parts);
}
/**
* Parse the main + featured artists for a track from its raw artist string and
* (optionally) its title. First named artist is main; everything else is
* featured. `featured` excludes the main artist and is deduped.
*/
export function parseArtists(
rawArtist: string,
rawTitle = ''
): { main: string; featured: string[] } {
const fromArtist = splitArtistNames(rawArtist || 'Unknown Artist');
// Pull any "(feat. X)" group out of the title and split it too.
const titleMatch = rawTitle.match(TITLE_FEAT_RE);
const fromTitle = titleMatch ? splitArtistNames(titleMatch[1]) : [];
const all = dedupe([...fromArtist, ...fromTitle]);
if (all.length === 0) return { main: rawArtist.trim() || 'Unknown Artist', featured: [] };
const main = all[0];
const mainKey = main.toLowerCase();
const featured = all.slice(1).filter((a) => a.toLowerCase() !== mainKey);
return { main, featured };
}
+167
View File
@@ -0,0 +1,167 @@
// Fuzzy string matching utilities for artist deduplication.
// Implements Levenshtein distance and Jaro-Winkler similarity.
/**
* Levenshtein distance between two strings.
* Returns the minimum number of single-character edits (insertions, deletions, substitutions)
* required to change one string into the other.
*/
export function levenshteinDistance(a: string, b: string): number {
if (a === b) return 0;
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix = new Array(a.length + 1).fill(null).map(() => new Array(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i++) matrix[i][0] = i;
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
);
}
}
return matrix[a.length][b.length];
}
/**
* Normalized Levenshtein similarity (0..1).
* 1 = identical, 0 = completely different.
*/
export function levenshteinSimilarity(a: string, b: string): number {
const maxLen = Math.max(a.length, b.length);
if (maxLen === 0) return 1;
const distance = levenshteinDistance(a, b);
return 1 - distance / maxLen;
}
/**
* Jaro similarity between two strings.
* Returns a value between 0 (no similarity) and 1 (identical).
*/
export function jaroSimilarity(a: string, b: string): number {
if (a === b) return 1;
if (a.length === 0 || b.length === 0) return 0;
const matchWindow = Math.floor(Math.max(a.length, b.length) / 2) - 1;
const aMatches = new Array(a.length).fill(false);
const bMatches = new Array(b.length).fill(false);
let matches = 0;
let transpositions = 0;
// Find matches
for (let i = 0; i < a.length; i++) {
const start = Math.max(0, i - matchWindow);
const end = Math.min(i + matchWindow + 1, b.length);
for (let j = start; j < end; j++) {
if (bMatches[j] || a[i] !== b[j]) continue;
aMatches[i] = bMatches[j] = true;
matches++;
break;
}
}
if (matches === 0) return 0;
// Count transpositions
let k = 0;
for (let i = 0; i < a.length; i++) {
if (!aMatches[i]) continue;
while (!bMatches[k]) k++;
if (a[i] !== b[k]) transpositions++;
k++;
}
const m = matches;
return (m / a.length + m / b.length + (m - transpositions / 2) / m) / 3;
}
/**
* Jaro-Winkler similarity.
* Gives more weight to common prefixes.
* Returns a value between 0 (no similarity) and 1 (identical).
*/
export function jaroWinklerSimilarity(a: string, b: string, prefixScale = 0.1): number {
const jaro = jaroSimilarity(a, b);
if (jaro < 0.7) return jaro; // Only boost if already somewhat similar
// Calculate common prefix length (max 4)
let prefixLen = 0;
const minLen = Math.min(a.length, b.length, 4);
for (let i = 0; i < minLen; i++) {
if (a[i] === b[i]) prefixLen++;
else break;
}
return jaro + prefixLen * prefixScale * (1 - jaro);
}
/**
* Normalize artist name for matching.
* Lowercase, strip diacritics, remove collaboration suffixes.
*/
export function normalizeForMatching(name: string): string {
return name
.toLowerCase()
.trim()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.replace(/\s*\(?\s*(feat(?:uring)?\.?|ft\.?|vs\.?|x)\s+.*$/i, '')
.replace(/,\s*$/, '')
.replace(/^([^,]+),.*$/, '$1')
.replace(/[&+]/g, 'and')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Find best match for a name among candidates.
* Returns { match: string, score: number } or null if no good match.
*/
export function findBestMatch<T extends { name: string }>(
query: string,
candidates: T[],
threshold = 0.9
): { match: T; score: number } | null {
const normalizedQuery = normalizeForMatching(query);
let bestMatch: { match: T; score: number } | null = null;
for (const candidate of candidates) {
const normalizedCandidate = normalizeForMatching(candidate.name);
// Exact normalized match
if (normalizedQuery === normalizedCandidate) {
return { match: candidate, score: 1 };
}
// Jaro-Winkler similarity
const score = jaroWinklerSimilarity(normalizedQuery, normalizedCandidate);
if (score >= threshold && (!bestMatch || score > bestMatch.score)) {
bestMatch = { match: candidate, score };
}
}
return bestMatch;
}
/**
* Check if two artist names likely represent the same artist.
*/
export function areSameArtist(name1: string, name2: string, threshold = 0.95): boolean {
const norm1 = normalizeForMatching(name1);
const norm2 = normalizeForMatching(name2);
if (norm1 === norm2) return true;
const score = jaroWinklerSimilarity(norm1, norm2);
return score >= threshold;
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "Node16",
"moduleResolution": "Node16",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["src/**/*"]
}