import type { Queryable } from './db.js'; import { MusicBrainzClient, LastFmClient, DiscogsClient, LrcLibClient, CoverArtClient, WikimediaClient, WikidataClient, FanartClient, TheAudioDbClient, ITunesClient, DeezerClient, upscaleITunesArtwork, } from './integrations/index.js'; import { MbSpineWriter } from './mb-spine-writer.js'; import { normalizeForMatching, jaroWinklerSimilarity, findBestMatch, } from './utils/fuzzy-match.js'; // Cap on how many merged genre tags we persist per track. Keeps track_genre // from ballooning with long-tail Last.fm/MB tags. const MAX_TAGS = 8; // MusicBrainz MBIDs are UUID-format strings, which fit the existing UUID-typed // artists.mbid column. Guard before writing so a malformed value never aborts // the UPDATE (and, by extension, the whole track enrichment). const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; interface TrackRow { id: string; title: string; artist: string; // raw artist string from track artist_name: string; // resolved main artist name from track_artists artist_mbid: string | null; // MusicBrainz ID of the main artist duration: number | null; album_id: string | null; album_title: string | null; artist_id: string | null; } /** Stored as BullMQ's job return value so a completed job never masquerades * as an enrichment hit when a toggle was off or every provider had no match. */ export interface EnrichmentJobOutcome { outcome: 'updated' | 'unchanged' | 'skipped' | 'not_found' | 'no_result'; detail?: string; } /** * Wires the external-integration clients into real metadata enrichment. * * Every provider call is best-effort: the clients themselves degrade to null/[] * when unconfigured or on error, and each provider step here is additionally * wrapped in its own try/catch so one failing provider can never abort the * others or fail the job. All writes are idempotent and parameterized. */ export class EnrichmentService { private readonly musicbrainz = new MusicBrainzClient(); private readonly lastfm = new LastFmClient(); private readonly discogs = new DiscogsClient(); private readonly lrclib = new LrcLibClient(); private readonly coverart = new CoverArtClient(); private readonly wikimedia = new WikimediaClient(); private readonly wikidata = new WikidataClient(); private readonly fanart = new FanartClient(); private readonly theaudiodb = new TheAudioDbClient(); private readonly itunes = new ITunesClient(); private readonly deezer = new DeezerClient(); constructor(private pgClient: Queryable) {} /** * Self-provision the enrichment-specific schema additions. Idempotent; mirrors * backend/src/db/schema.sql so the worker runs on older DBs. The existing * columns it reuses (artists.mbid/discogs_id, albums.year/artwork_id) are * guarded with ADD COLUMN IF NOT EXISTS for the same reason. */ async ensureSchema(): Promise { await this.pgClient.query( `CREATE TABLE IF NOT EXISTS artist_similar ( artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, similar_name TEXT NOT NULL, match REAL NOT NULL DEFAULT 0, fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (artist_id, similar_name) )` ); // New artist identity tables await this.pgClient.query( `ALTER TABLE artists ADD COLUMN IF NOT EXISTS canonical_name TEXT` ); await this.pgClient.query( `ALTER TABLE artists ADD COLUMN IF NOT EXISTS sort_name TEXT` ); await this.pgClient.query( `ALTER TABLE artists ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` ); await this.pgClient.query( `ALTER TABLE artists ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` ); await this.pgClient.query( `CREATE TABLE IF NOT EXISTS artist_aliases ( artist_id UUID NOT NULL REFERENCES artists(id) ON DELETE CASCADE, alias TEXT NOT NULL, alias_normalized TEXT GENERATED ALWAYS AS (normalize_artist(alias)) STORED, PRIMARY KEY (artist_id, alias) )` ); await this.pgClient.query( `CREATE INDEX IF NOT EXISTS idx_artist_aliases_normalized ON artist_aliases(alias_normalized)` ); await this.pgClient.query( `CREATE TABLE IF NOT EXISTS artist_lookup_cache ( normalized_name TEXT PRIMARY KEY, mbid UUID, canonical_name TEXT, sort_name TEXT, fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, not_found BOOLEAN DEFAULT FALSE )` ); await this.pgClient.query( `ALTER TABLE albums ADD COLUMN IF NOT EXISTS mbid UUID` ); await this.pgClient.query( `CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL` ); await this.pgClient.query( `CREATE INDEX IF NOT EXISTS idx_albums_mbid ON albums(mbid) WHERE mbid IS NOT NULL` ); // Defensive: ensure the reused columns exist on older databases. await this.pgClient.query( `ALTER TABLE artists ADD COLUMN IF NOT EXISTS mbid UUID` ); await this.pgClient.query( `ALTER TABLE artists ADD COLUMN IF NOT EXISTS discogs_id TEXT` ); await this.pgClient.query( `ALTER TABLE albums ADD COLUMN IF NOT EXISTS year INTEGER` ); await this.pgClient.query( `ALTER TABLE albums ADD COLUMN IF NOT EXISTS artwork_id TEXT` ); // release_date: the actual album release date (from MusicBrainz // first-release-date). Distinct from `year` (which can also come from // Discogs) — release_date is the full ISO date and serves as a // deterministic tiebreaker in album dedup (earlier release = keeper). await this.pgClient.query( `ALTER TABLE albums ADD COLUMN IF NOT EXISTS release_date DATE` ); // created_at: when the album row was inserted into the DB. Used as a // secondary tiebreaker in dedup (oldest row = keeper). await this.pgClient.query( `ALTER TABLE albums ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` ); } private async loadTrack(trackId: string): Promise { const res = await this.pgClient.query( `SELECT t.id, t.title, t.artist, t.duration, t.album_id, al.title AS album_title, ar.id AS artist_id, ar.name AS artist_name, ar.mbid AS artist_mbid FROM tracks t LEFT JOIN albums al ON al.id = t.album_id LEFT JOIN track_artists ta ON ta.track_id = t.id AND ta.role = 'main' LEFT JOIN artists ar ON ar.id = ta.artist_id WHERE t.id = $1`, [trackId] ); return res.rows[0] ?? null; } /** Idempotent upsert of a genre by name; returns its id. */ private async upsertGenre(name: string): Promise { const res = await this.pgClient.query<{ id: string }>( `INSERT INTO genre (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name] ); return res.rows[0].id; } /** Idempotent upsert of a (track, genre) weight. */ private async upsertTrackGenre( trackId: string, genreId: string, weight: number ): Promise { await this.pgClient.query( `INSERT INTO track_genre (track_id, genre_id, weight) VALUES ($1, $2, $3) ON CONFLICT (track_id, genre_id) DO UPDATE SET weight = EXCLUDED.weight`, [trackId, genreId, weight] ); } /** * Resolve artist identity using the full pipeline: * 1. Check artist_lookup_cache (normalized name -> MBID) * 2. Exact match on normalized_name * 3. Fuzzy match (Jaro-Winkler > 0.95) * 4. MusicBrainz lookup * 5. Create new artist with canonical_name from MB * 6. Store aliases for future matching */ async resolveArtistIdentity(rawArtistName: string): Promise<{ artistId: string; canonicalName: string; mbid: string | null; sortName: string | null; isNew: boolean; }> { const normalized = normalizeForMatching(rawArtistName); // 1. Check cache first const cacheHit = await this.pgClient.query( `SELECT mbid, canonical_name, sort_name, not_found FROM artist_lookup_cache WHERE normalized_name = $1`, [normalized] ); if (cacheHit.rows.length > 0) { const row = cacheHit.rows[0]; if (row.not_found) { // Negative cache hit - create local artist without MBID return this.createLocalArtist(rawArtistName, normalized); } if (row.mbid) { // Look up existing artist by MBID const existing = await this.pgClient.query( `SELECT id, canonical_name, sort_name FROM artists WHERE mbid = $1`, [row.mbid] ); if (existing.rows.length > 0) { const canonical = existing.rows[0].canonical_name || rawArtistName; return { artistId: existing.rows[0].id, canonicalName: canonical, mbid: row.mbid, sortName: existing.rows[0].sort_name || this.generateSortName(canonical), isNew: false, }; } } } // 2. Exact match on normalized_name const exactMatch = await this.pgClient.query( `SELECT id, canonical_name, mbid, sort_name FROM artists WHERE normalized_name = $1`, [normalized] ); if (exactMatch.rows.length > 0) { const row = exactMatch.rows[0]; const canonical = row.canonical_name || rawArtistName; // Update cache await this.updateArtistLookupCache(normalized, row.mbid, row.canonical_name, row.sort_name, false); return { artistId: row.id, canonicalName: canonical, mbid: row.mbid, sortName: row.sort_name || this.generateSortName(canonical), isNew: false, }; } // 3. Fuzzy match against existing artists const allArtists = await this.pgClient.query( `SELECT id, name, canonical_name, mbid, normalized_name FROM artists` ); const fuzzyMatch = findBestMatch( normalized, allArtists.rows.map(r => ({ ...r, name: r.canonical_name || r.name })), 0.95 ); if (fuzzyMatch) { const row = fuzzyMatch.match; const canonical = row.canonical_name || rawArtistName; // Add alias for future exact matches await this.addArtistAlias(row.id, rawArtistName); // Update cache await this.updateArtistLookupCache(normalized, row.mbid, row.canonical_name, row.sort_name, false); return { artistId: row.id, canonicalName: canonical, mbid: row.mbid, sortName: row.sort_name || this.generateSortName(canonical), isNew: false, }; } // 4. MusicBrainz artist search (canonical identity) // Uses the /artist endpoint directly — the correct source for canonical // name + sort-name + MBID. The previous implementation mis-used // lookupRecording(rawName, '') (a recording search with an empty title) // which was unreliable and never returned sort-name. if (this.musicbrainz['cfg']?.contact) { try { const mbArtist = await this.musicbrainz.searchArtist(rawArtistName); if (mbArtist && UUID_RE.test(mbArtist.artistMbid)) { // Check if an artist with this MBID already exists const mbArtistRow = await this.pgClient.query( `SELECT id, canonical_name, sort_name FROM artists WHERE mbid = $1`, [mbArtist.artistMbid] ); if (mbArtistRow.rows.length > 0) { const row = mbArtistRow.rows[0]; await this.addArtistAlias(row.id, rawArtistName); await this.updateArtistLookupCache(normalized, mbArtist.artistMbid, row.canonical_name, row.sort_name, false); return { artistId: row.id, canonicalName: row.canonical_name || mbArtist.name, mbid: mbArtist.artistMbid, sortName: row.sort_name || mbArtist.sortName || this.generateSortName(mbArtist.name), isNew: false, }; } // Create new artist with MB canonical name + sort-name const canonicalName = mbArtist.name; const sortName = mbArtist.sortName || this.generateSortName(canonicalName); const newArtist = await this.pgClient.query( // normalized_name is a GENERATED ALWAYS column in schema.sql // (normalize_artist(name)); writing to it explicitly errors with // 428C9 on any database built from schema.sql. Let Postgres derive it. `INSERT INTO artists (name, canonical_name, sort_name, mbid) VALUES ($1, $2, $3, $4) ON CONFLICT (mbid) DO UPDATE SET canonical_name = EXCLUDED.canonical_name, sort_name = EXCLUDED.sort_name, name = EXCLUDED.name RETURNING id`, [rawArtistName, canonicalName, sortName, mbArtist.artistMbid] ); const artistId = newArtist.rows[0].id; await this.addArtistAlias(artistId, rawArtistName); await this.updateArtistLookupCache(normalized, mbArtist.artistMbid, canonicalName, sortName, false); return { artistId, canonicalName, mbid: mbArtist.artistMbid, sortName, isNew: true, }; } } catch (err) { console.warn('[Enrich] MusicBrainz artist search failed:', (err as Error).message); } } // 5. Create local artist without MBID return this.createLocalArtist(rawArtistName, normalized); } private async createLocalArtist(rawName: string, normalized: string): Promise<{ artistId: string; canonicalName: string; mbid: string | null; sortName: string; isNew: boolean; }> { const sortName = this.generateSortName(rawName); const result = await this.pgClient.query( // normalized_name is GENERATED ALWAYS (normalize_artist(name)) in // schema.sql — inserting it explicitly fails with 428C9. Derived by PG. `INSERT INTO artists (name, canonical_name, sort_name) VALUES ($1, $2, $3) RETURNING id`, [rawName, rawName, sortName] ); const artistId = result.rows[0].id; await this.addArtistAlias(artistId, rawName); await this.updateArtistLookupCache(normalized, null, rawName, sortName, true); return { artistId, canonicalName: rawName, mbid: null, sortName, isNew: true, }; } private generateSortName(name: string): string { // Move "The ", "A ", "An " to end: "The Beatles" -> "Beatles, The" const match = name.match(/^(The|A|An)\s+(.+)$/i); if (match) { return `${match[2]}, ${match[1]}`; } return name; } private async addArtistAlias(artistId: string, alias: string): Promise { try { await this.pgClient.query( `INSERT INTO artist_aliases (artist_id, alias) VALUES ($1, $2) ON CONFLICT (artist_id, alias) DO NOTHING`, [artistId, alias] ); } catch { // Ignore duplicate } } private async updateArtistLookupCache( normalizedName: string, mbid: string | null, canonicalName: string | null, sortName: string | null, notFound: boolean ): Promise { await this.pgClient.query( `INSERT INTO artist_lookup_cache (normalized_name, mbid, canonical_name, sort_name, not_found) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (normalized_name) DO UPDATE SET mbid = EXCLUDED.mbid, canonical_name = EXCLUDED.canonical_name, sort_name = EXCLUDED.sort_name, not_found = EXCLUDED.not_found, fetched_at = CURRENT_TIMESTAMP`, [normalizedName, mbid, canonicalName, sortName, notFound] ); } /** * Autonomous artist image pipeline with fallback chain: * 1. Fanart.tv (by MBID) - best quality, structured * 2. TheAudioDB (by MBID) - good coverage * 3. Wikidata (MBID -> Wikidata ID -> P18 image) * 4. Last.fm (by name) * 5. Discogs (by name + MBID) * 6. Wikimedia Commons (by name) * 7. Placeholder (none) */ async getArtistImage(artistId: string, mbid: string | null, artistName: string): Promise { // If we already have a good image, skip const existing = await this.pgClient.query( `SELECT image_path FROM artists WHERE id = $1`, [artistId] ); if (existing.rows[0]?.image_path && !existing.rows[0].image_path.includes('2a96cbd8b46e442fc41c2b86b821562f')) { return existing.rows[0].image_path; } // 1. Fanart.tv (best for structured artist images) if (mbid) { try { const fanartImage = await this.fanart.getBestArtistImage(mbid); if (fanartImage) { await this.updateArtistImage(artistId, fanartImage); return fanartImage; } } catch (err) { console.warn('[Enrich] Fanart.tv image failed:', (err as Error).message); } } // 2. TheAudioDB (by MBID) if (mbid) { try { const adbImage = await this.theaudiodb.getBestArtistImage(mbid); if (adbImage) { await this.updateArtistImage(artistId, adbImage); return adbImage; } } catch (err) { console.warn('[Enrich] TheAudioDB image failed:', (err as Error).message); } } // 3. Wikidata (MBID -> Wikidata -> P18) if (mbid) { try { const wikiImage = await this.wikidata.getArtistImageFromMbid(mbid); if (wikiImage) { await this.updateArtistImage(artistId, wikiImage); return wikiImage; } } catch (err) { console.warn('[Enrich] Wikidata image failed:', (err as Error).message); } } // 4. Last.fm (by name) try { const lfmImage = await this.lastfm.getArtistImageUrl(artistName); if (lfmImage) { await this.updateArtistImage(artistId, lfmImage); return lfmImage; } } catch (err) { console.warn('[Enrich] Last.fm image failed:', (err as Error).message); } // 5. Discogs (by name + MBID for exact match, fallback to name only) try { const discogsImage = await this.discogs.getArtistImageUrl(artistName, mbid ?? undefined); if (discogsImage) { await this.updateArtistImage(artistId, discogsImage); return discogsImage; } } catch (err) { console.warn('[Enrich] Discogs image failed:', (err as Error).message); } // 6. Deezer (by name, no auth). The five sources above cover almost nothing // in this library: Fanart needs a key the worker does not have, TheAudioDB // and Discogs 404 on most names, Wikidata needs an MBID that 638 of 734 // artists lack, and Last.fm stopped serving real artist photos. try { const deezerImage = await this.deezer.searchArtistImage(artistName); if (deezerImage) { await this.updateArtistImage(artistId, deezerImage); return deezerImage; } } catch (err) { console.warn('[Enrich] Deezer image failed:', (err as Error).message); } // NOTE: Wikimedia Commons "by name" was previously the last fallback, but a // blind File-namespace text search (" artist") routinely returns the // wrong image entirely (e.g. an unrelated person who shares the name). It has // been removed as an image source. MBID-verified Wikidata images (step 3) // still flow through and are accurate. Artists with no better match get the // placeholder rather than a misleading photo. return null; } private async updateArtistImage(artistId: string, imageUrl: string): Promise { await this.pgClient.query( `UPDATE artists SET image_path = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [imageUrl, artistId] ); } /** * Best-effort enrichment for a single track. Each provider is isolated: a * provider that returns nothing or throws is logged and skipped without * affecting the others. Safe to re-run (stable, no duplicate rows). */ async enrichTrack(trackId: string): Promise { const track = await this.loadTrack(trackId); if (!track) { console.warn(`[Enrich] track not found: ${trackId}`); return { outcome: 'not_found', detail: 'Track no longer exists.' }; } // Load enrichment settings. Default all to true (best-effort). const settings = await this.loadSettings(); const trackEnrichmentEnabled = settings.enrich_metadata || settings.enrich_genres || settings.enrich_lyrics; if (!trackEnrichmentEnabled) { console.log(`[Enrich] track ${trackId}: skipped (all track enrichment toggles disabled)`); return { outcome: 'skipped', detail: 'All track enrichment toggles are disabled.' }; } const summary: string[] = []; const album = track.album_title ?? undefined; // Merged genre/tag signals (name -> max weight) from MB + Last.fm. const tagWeights = new Map(); let recordingMatch: Awaited< ReturnType > = null; // --- 0. Resolve artist identity using new pipeline ----------------------- let artistIdentity = null; if (settings.enrich_metadata && track.artist_id) { try { artistIdentity = await this.resolveArtistIdentity(track.artist_name); summary.push(`artist_id:${artistIdentity.isNew ? 'new' : 'existing'}`); } catch (err) { console.warn('[Enrich] Artist identity resolution failed:', (err as Error).message); } } // --- a/b. MusicBrainz: recording lookup + artist MBID + artist tags ------ if (settings.enrich_metadata) { try { recordingMatch = await this.musicbrainz.lookupRecording( track.artist, track.title, album ); const artistId = artistIdentity?.artistId ?? track.artist_id; const artistMbid = artistIdentity?.mbid ?? recordingMatch?.artistMbid; if (artistMbid && artistId && UUID_RE.test(artistMbid)) { await this.pgClient.query( `UPDATE artists SET mbid = $1 WHERE id = $2 AND mbid IS DISTINCT FROM $1`, [artistMbid, artistId] ); summary.push('mbid'); } // E. Write back MB canonical artist name + sort-name. The recording match // carries the canonical artist name from MB's artist-credit; we persist // it so display/join uses the verified name ("P!nk" not "Pink"). // resolveArtistIdentity only sets canonical_name on create/MBID-match; // this catches artists resolved earlier by fuzzy/normalized match that // still carry the raw tag as canonical_name. if (recordingMatch?.artistName && artistId) { const sortName = this.generateSortName(recordingMatch.artistName); await this.pgClient.query( `UPDATE artists SET canonical_name = $1, sort_name = COALESCE(sort_name, $2), updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND (canonical_name IS DISTINCT FROM $1 OR sort_name IS NULL)`, [recordingMatch.artistName, sortName, artistId] ); summary.push('canonical_name'); } // F. MB spine: write credited_main_on / featured_on claims from full // artist-credit (not just the primary artist we already resolved above). if (recordingMatch?.recordingMbid) { try { const spineWriter = new MbSpineWriter(this.pgClient); const n = await spineWriter.writeRecordingClaims( recordingMatch.recordingMbid, track.id, this.musicbrainz, ); if (n > 0) summary.push(`spine:${n}`); } catch (err) { console.warn('[Enrich] MB spine writer failed:', (err as Error).message); } } // F'. MB spine (artist relations): write member_of / alias_of claims from // the artist's MB artist-relations (identity fusion source e.g. // DOOM/Madvillain/Viktor Vaughn collapse). Only when we have a stable // artist MBID and a resolved local artist entity. if (artistMbid && artistId && UUID_RE.test(artistMbid)) { try { const spineWriter = new MbSpineWriter(this.pgClient); const n = await spineWriter.writeArtistRelationClaims( artistMbid, artistId, this.musicbrainz, ); if (n > 0) summary.push(`artist-relations:${n}`); } catch (err) { console.warn('[Enrich] MB artist-relation spine writer failed:', (err as Error).message); } } if (artistMbid) { const mbTags = await this.musicbrainz.getArtistTags(artistMbid); for (const t of mbTags) { tagWeights.set(t.name, Math.max(tagWeights.get(t.name) ?? 0, t.weight)); } if (mbTags.length > 0) summary.push(`mb-tags:${mbTags.length}`); } } catch (err) { console.warn('[Enrich] MusicBrainz step failed:', (err as Error).message); } } // enrich_metadata // NOTE: Artist images are no longer fetched inline here. They run as their // own `artist_image` job (enqueued by the scanner), so image HTTP lookups // don't block track enrichment. See refreshArtistImage(). // --- b. Last.fm artist top tags ----------------------------------------- if (settings.enrich_genres) { try { const lfmTags = await this.lastfm.getArtistTopTags(track.artist); for (const t of lfmTags) { tagWeights.set(t.name, Math.max(tagWeights.get(t.name) ?? 0, t.weight)); } if (lfmTags.length > 0) summary.push(`lfm-tags:${lfmTags.length}`); } catch (err) { console.warn('[Enrich] Last.fm step failed:', (err as Error).message); } } // enrich_genres // --- c. Persist merged tags as genre + track_genre (top MAX_TAGS) ------- if (settings.enrich_genres) { try { const top = [...tagWeights.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, MAX_TAGS); for (const [name, weight] of top) { const genreId = await this.upsertGenre(name); await this.upsertTrackGenre(track.id, genreId, weight); } if (top.length > 0) summary.push(`genres:${top.length}`); } catch (err) { console.warn('[Enrich] genre persistence failed:', (err as Error).message); } } // enrich_genres // --- d. LRCLib lyrics ---------------------------------------------------- if (settings.enrich_lyrics) { try { const lyrics = await this.lrclib.getLyrics( track.artist, track.title, album, track.duration ?? undefined ); if (lyrics && (lyrics.plainLyrics || lyrics.syncedLyrics)) { await this.pgClient.query( `INSERT INTO track_lyrics (track_id, lyrics_text, provider, synced_lyrics) VALUES ($1, $2, $3, $4) ON CONFLICT (track_id) DO UPDATE SET lyrics_text = EXCLUDED.lyrics_text, provider = EXCLUDED.provider, synced_lyrics = EXCLUDED.synced_lyrics`, [ track.id, lyrics.plainLyrics, lyrics.provider, lyrics.syncedLyrics !== null ? JSON.stringify(lyrics.syncedLyrics) : null, ] ); summary.push('lyrics'); } } catch (err) { console.warn('[Enrich] LRCLib step failed:', (err as Error).message); } } // enrich_lyrics // --- e. Discogs: discogs_id + album year (when enrich_metadata is on) ---- // Album cover art itself is fetched by the separate `album_cover` job. if (settings.enrich_metadata) { try { if (album && track.artist_id) { const rel = await this.discogs.searchRelease(track.artist, album); if (rel) { await this.pgClient.query( `UPDATE artists SET discogs_id = $1 WHERE id = $2 AND discogs_id IS DISTINCT FROM $1`, [String(rel.discogsId), track.artist_id] ); summary.push('discogs_id'); if (track.album_id && rel.year !== null) { await this.pgClient.query( `UPDATE albums SET year = $1 WHERE id = $2 AND year IS DISTINCT FROM $1`, [rel.year, track.album_id] ); summary.push('year'); } } } } catch (err) { console.warn('[Enrich] Discogs metadata step failed:', (err as Error).message); } } // enrich_metadata // --- f. MusicBrainz release-group: canonical album title + MBID + date --- // A release-group is MB's canonical "album" entity (groups all editions of // the same album under one stable MBID). We write back the canonical title // (fixing misspelled / mistagged album names), the release-group MBID (the // stable album identity), the first-release date (full ISO date from MB — // more precise than Discogs's year-only), and the year as a fallback when // Discogs didn't already set one. Score-gated at 80 to avoid bad matches. if (settings.enrich_metadata && track.album_id && album) { try { const artistNameForSearch = artistIdentity?.canonicalName ?? track.artist_name ?? track.artist; const rg = await this.musicbrainz.searchReleaseGroup(artistNameForSearch, album); if (rg && track.album_id) { // Set canonical title always; set release-group MBID only when this // album doesn't already have one (avoids UNIQUE(mbid) collisions when // two local album rows resolve to the same release-group — those are // duplicates to be merged separately); fill release_date + year as // COALESCE fallbacks so we don't overwrite richer data from Discogs. await this.pgClient.query( `UPDATE albums SET title = $1, mbid = COALESCE(mbid, $2), release_date = COALESCE(release_date, $3::date), year = COALESCE(year, $4::int) WHERE id = $5 AND (title IS DISTINCT FROM $1 OR mbid IS NULL OR release_date IS NULL OR (year IS NULL AND $4::int IS NOT NULL))`, [rg.title, rg.releaseGroupMbid, rg.firstReleaseDate, rg.year, track.album_id] ); summary.push(`album:${rg.title === album ? 'mbid' : 'canonical'}`); // G'. MB spine (album): write credited_main_on_album / featured_on_album // from the release-group's full artist-credit. Uses the album's MBID // if it's already persisted (avoids re-fetching on re-enrichment). try { const albumRow = await this.pgClient.query<{ mbid: string | null }>( `SELECT mbid FROM albums WHERE id = $1`, [track.album_id] ); const albumMbid = albumRow.rows[0]?.mbid ?? rg.releaseGroupMbid; if (albumMbid) { const spineWriter = new MbSpineWriter(this.pgClient); const n = await spineWriter.writeAlbumClaims( albumMbid, track.album_id, this.musicbrainz, ); if (n > 0) summary.push(`album-spine:${n}`); } } catch (err) { console.warn('[Enrich] MB album spine writer failed:', (err as Error).message); } } } catch (err) { console.warn('[Enrich] MusicBrainz release-group step failed:', (err as Error).message); } } // enrich_metadata // NOTE: Album cover art is fetched by the separate `album_cover` job (Discogs // + Cover Art Archive), not inline here. See refreshAlbumCover(). console.log( `[Enrich] track ${trackId} enriched: ${ summary.length > 0 ? summary.join(', ') : 'nothing' }` ); return summary.length > 0 ? { outcome: 'updated', detail: summary.join(', ') } : { outcome: 'no_result', detail: 'No provider returned usable metadata.' }; } /** * Refresh Last.fm similar-artist rows for one artist. Best-effort and * idempotent (upsert keyed on (artist_id, similar_name)). */ /** * Read enrichment settings from the database. Returns an object with boolean * values for each toggle; defaults to true when a row is missing or malformed. */ private async loadSettings(): Promise<{ enrich_metadata: boolean; enrich_artist_images: boolean; enrich_genres: boolean; enrich_cover_art: boolean; enrich_lyrics: boolean; enrich_artist_similarity: boolean; enrich_audio_analysis: boolean; }> { const rows = await this.pgClient.query( `SELECT key, value FROM settings WHERE key IN ('enrich_metadata','enrich_artist_images','enrich_genres','enrich_cover_art', 'enrich_lyrics','enrich_artist_similarity','enrich_audio_analysis')` ); const map: Record = {}; for (const r of rows.rows) { map[r.key] = r.value === 'true'; } return { enrich_metadata: map.enrich_metadata ?? true, // This migration is intentionally opt-in: image cleanup must never be // followed by an unrequested external API fan-out on the next scan. enrich_artist_images: map.enrich_artist_images ?? false, enrich_genres: map.enrich_genres ?? true, enrich_cover_art: map.enrich_cover_art ?? true, enrich_lyrics: map.enrich_lyrics ?? true, enrich_artist_similarity: map.enrich_artist_similarity ?? true, enrich_audio_analysis: map.enrich_audio_analysis ?? false, }; } /** * Fetch/refresh a single artist's image via the fallback chain. Runs as the * dedicated `artist_image` job so image lookups don't run inline with track * enrichment. Best-effort and idempotent — getArtistImage() short-circuits * when a good image already exists. Gated by the dedicated * enrich_artist_images toggle, independently of structural metadata. */ async refreshArtistImage(artistId: string): Promise { const settings = await this.loadSettings(); if (!settings.enrich_artist_images) { console.log(`[Enrich] artist image ${artistId}: skipped (enrich_artist_images disabled)`); return { outcome: 'skipped', detail: 'enrich_artist_images is disabled.' }; } const res = await this.pgClient.query<{ name: string; canonical_name: string | null; mbid: string | null; image_path: string | null }>( `SELECT name, canonical_name, mbid, image_path FROM artists WHERE id = $1`, [artistId] ); const artist = res.rows[0]; if (!artist) { console.warn(`[Enrich] artist not found for image: ${artistId}`); return { outcome: 'not_found', detail: 'Artist no longer exists.' }; } if (artist.image_path) return { outcome: 'unchanged', detail: 'Artist already has an image.' }; const url = await this.getArtistImage(artistId, artist.mbid, artist.canonical_name ?? artist.name); console.log(`[Enrich] artist image ${artistId}: ${url ? 'set' : 'none'}`); return url ? { outcome: 'updated', detail: 'Artist image was set.' } : { outcome: 'no_result', detail: 'No verified artist image was found.' }; } /** * Fetch/refresh a single album's cover art. Runs as the dedicated `album_cover` * job. Best-effort and idempotent — skips when the album already has artwork. * Gated by enrich_cover_art. * * =================== COVER ART FALLBACK CHAIN ======================= * Five sources are tried in order of accuracy + coverage: * 1. Cover Art Archive (release-group) — exact MB-verified match via the * release-group MBID now stored in albums.mbid. Always correct when it * hits; no search step needed. * 2. iTunes Search — best coverage of any source, 600×600 artwork. No auth. * 3. Deezer Search — high quality (1000×1000 cover_xl), no auth. * 4. Discogs — good for vinyl/physical releases; also yields discogs_id. * 5. Cover Art Archive (release) — last resort, via a recording search to * resolve a release MBID. Slowest, but catches releases without a * release-group cover. * Each step short-circuits on the first hit. */ async refreshAlbumCover(albumId: string): Promise { const settings = await this.loadSettings(); if (!settings.enrich_cover_art) { return { outcome: 'skipped', detail: 'enrich_cover_art is disabled.' }; } const albumRes = await this.pgClient.query<{ title: string; mbid: string | null; artist_id: string | null; artwork_id: string | null; }>( `SELECT title, mbid, artist_id, artwork_id FROM albums WHERE id = $1`, [albumId] ); const album = albumRes.rows[0]; if (!album) { console.warn(`[Enrich] album not found for cover: ${albumId}`); return { outcome: 'not_found', detail: 'Album no longer exists.' }; } if (album.artwork_id) return { outcome: 'unchanged', detail: 'Album already has artwork.' }; let artistName = ''; if (album.artist_id) { const a = await this.pgClient.query<{ name: string; canonical_name: string | null }>( `SELECT name, canonical_name FROM artists WHERE id = $1`, [album.artist_id] ); artistName = a.rows[0]?.canonical_name ?? a.rows[0]?.name ?? ''; } // 1. Cover Art Archive (release-group) — exact, no search, no auth. // albums.mbid now holds a release-group MBID from the MB release-group // search step in enrichTrack. if (album.mbid) { try { const coverUrl = await this.coverart.getReleaseGroupCoverUrl(album.mbid); if (coverUrl) { await this.pgClient.query( `UPDATE albums SET artwork_id = $1 WHERE id = $2 AND artwork_id IS DISTINCT FROM $1`, [coverUrl, albumId] ); console.log(`[Enrich] album cover ${albumId}: caa-release-group`); return { outcome: 'updated', detail: 'Cover Art Archive release-group.' }; } } catch (err) { console.warn('[Enrich] album cover CAA release-group step failed:', (err as Error).message); } } // 2. iTunes Search — best coverage, 600×600, no auth. if (artistName) { try { const itunesAlbum = await this.itunes.searchAlbum(artistName, album.title); if (itunesAlbum) { const coverUrl = upscaleITunesArtwork(itunesAlbum.artworkUrl100, 600); await this.pgClient.query( `UPDATE albums SET artwork_id = $1 WHERE id = $2 AND artwork_id IS DISTINCT FROM $1`, [coverUrl, albumId] ); console.log(`[Enrich] album cover ${albumId}: itunes`); return { outcome: 'updated', detail: 'iTunes artwork.' }; } } catch (err) { console.warn('[Enrich] album cover iTunes step failed:', (err as Error).message); } } // 3. Deezer Search — high quality (1000×1000), no auth. if (artistName) { try { const deezerAlbum = await this.deezer.searchAlbum(artistName, album.title); if (deezerAlbum) { await this.pgClient.query( `UPDATE albums SET artwork_id = $1 WHERE id = $2 AND artwork_id IS DISTINCT FROM $1`, [deezerAlbum.coverXl, albumId] ); console.log(`[Enrich] album cover ${albumId}: deezer`); return { outcome: 'updated', detail: 'Deezer artwork.' }; } } catch (err) { console.warn('[Enrich] album cover Deezer step failed:', (err as Error).message); } } // 4. Discogs — good for vinyl/physical releases; also yields discogs_id. if (artistName) { try { const rel = await this.discogs.searchRelease(artistName, album.title); if (rel?.coverImage) { await this.pgClient.query( `UPDATE albums SET artwork_id = $1 WHERE id = $2 AND artwork_id IS DISTINCT FROM $1`, [rel.coverImage, albumId] ); // Also persist discogs_id on the artist if this is the first time. if (album.artist_id && rel.discogsId) { await this.pgClient.query( `UPDATE artists SET discogs_id = $1 WHERE id = $2 AND discogs_id IS DISTINCT FROM $1`, [String(rel.discogsId), album.artist_id] ); } console.log(`[Enrich] album cover ${albumId}: discogs`); return { outcome: 'updated', detail: 'Discogs artwork.' }; } } catch (err) { console.warn('[Enrich] album cover Discogs step failed:', (err as Error).message); } } // 5. Cover Art Archive (release) — last resort, via a recording search to // resolve a release MBID. Slowest, but catches releases without a // release-group cover. try { const trackRes = await this.pgClient.query<{ title: string }>( `SELECT title FROM tracks WHERE album_id = $1 LIMIT 1`, [albumId] ); const sample = trackRes.rows[0]; if (sample && artistName) { const rec = await this.musicbrainz.lookupRecording(artistName, sample.title, album.title); if (rec?.releaseMbid) { const coverUrl = await this.coverart.getReleaseCoverUrl(rec.releaseMbid); if (coverUrl) { await this.pgClient.query( `UPDATE albums SET artwork_id = $1 WHERE id = $2 AND artwork_id IS NULL`, [coverUrl, albumId] ); console.log(`[Enrich] album cover ${albumId}: caa-release`); return { outcome: 'updated', detail: 'Cover Art Archive release.' }; } } } } catch (err) { console.warn('[Enrich] album cover CAA release step failed:', (err as Error).message); } console.log(`[Enrich] album cover ${albumId}: none`); return { outcome: 'no_result', detail: 'No cover provider returned artwork.' }; } async refreshArtistSimilarity(artistId: string): Promise { const settings = await this.loadSettings(); if (!settings.enrich_artist_similarity) return; const res = await this.pgClient.query<{ name: string }>( `SELECT name FROM artists WHERE id = $1`, [artistId] ); const artist = res.rows[0]; if (!artist) { console.warn(`[Enrich] artist not found: ${artistId}`); return; } let count = 0; try { const similar = await this.lastfm.getSimilarArtists(artist.name); for (const s of similar) { // Store the similar_name run through normalize_artist() so the Vibe // engine's join (similar_name = tracks.normalized_artist) matches // case- and feature-insensitively. Without this, Last.fm's "The // Beatles" never joins to a track tagged "the beatles". const normalizedSimilar = await this.pgClient.query( `SELECT normalize_artist($1) AS n`, [s.name] ); const similarKey = normalizedSimilar.rows[0]?.n ?? s.name; await this.pgClient.query( `INSERT INTO artist_similar (artist_id, similar_name, match, fetched_at) VALUES ($1, $2, $3, NOW()) ON CONFLICT (artist_id, similar_name) DO UPDATE SET match = EXCLUDED.match, fetched_at = NOW()`, [artistId, similarKey, s.match] ); count++; } } catch (err) { console.warn( '[Enrich] refreshArtistSimilarity failed:', (err as Error).message ); } console.log( `[Enrich] artist ${artistId} (${artist.name}) similarity: ${count} rows` ); } /** * Analyze current artist identification state. * Returns stats about MBID coverage, potential duplicates, etc. */ async analyzeArtistIdentification(): Promise<{ totalArtists: number; withMbid: number; withoutMbid: number; withCanonicalName: number; withImage: number; potentialDuplicates: Array<{ name: string; count: number; ids: string[] }>; aliasCount: number; cacheSize: number; }> { const totalRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artists`); const mbidRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artists WHERE mbid IS NOT NULL`); const canonicalRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artists WHERE canonical_name IS NOT NULL`); const imageRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artists WHERE image_path IS NOT NULL AND image_path != ''`); const aliasRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artist_aliases`); const cacheRes = await this.pgClient.query(`SELECT COUNT(*)::int AS n FROM artist_lookup_cache`); // Find potential duplicates by normalized name const dupRes = await this.pgClient.query( `SELECT normalized_name, COUNT(*)::int AS cnt, array_agg(id) AS ids, array_agg(name) AS names FROM artists GROUP BY normalized_name HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 20` ); const potentialDuplicates = dupRes.rows.map(r => ({ name: r.normalized_name, count: r.cnt, ids: r.ids, })); return { totalArtists: totalRes.rows[0].n, withMbid: mbidRes.rows[0].n, withoutMbid: totalRes.rows[0].n - mbidRes.rows[0].n, withCanonicalName: canonicalRes.rows[0].n, withImage: imageRes.rows[0].n, potentialDuplicates, aliasCount: aliasRes.rows[0].n, cacheSize: cacheRes.rows[0].n, }; } /** * Verify a specific artist's identity resolution. * Shows what the pipeline would produce for a given raw name. */ async verifyArtistIdentity(rawName: string): Promise<{ input: string; normalized: string; cacheHit: boolean; exactMatch: { id: string; canonical_name: string; mbid: string | null } | null; fuzzyMatch: { id: string; canonical_name: string; mbid: string | null; score: number } | null; mbResult: { artistMbid: string | null; artistName: string | null; sortName: string | null; score: number } | null; resolved: { artistId: string; canonicalName: string; mbid: string | null; isNew: boolean } | null; }> { const normalized = normalizeForMatching(rawName); // Check cache const cacheHit = await this.pgClient.query( `SELECT * FROM artist_lookup_cache WHERE normalized_name = $1`, [normalized] ); // Exact match const exactMatch = await this.pgClient.query( `SELECT id, canonical_name, mbid FROM artists WHERE normalized_name = $1`, [normalized] ); // Fuzzy match const allArtists = await this.pgClient.query( `SELECT id, name, canonical_name, mbid, normalized_name FROM artists` ); const fuzzyMatch = findBestMatch( normalized, allArtists.rows.map(r => ({ ...r, name: r.canonical_name || r.name })), 0.95 ); // MusicBrainz artist search let mbResult = null; if (this.musicbrainz['cfg']?.contact) { try { const mbArtist = await this.musicbrainz.searchArtist(rawName); if (mbArtist) { mbResult = { artistMbid: mbArtist.artistMbid, artistName: mbArtist.name, sortName: mbArtist.sortName, score: mbArtist.score, }; } } catch { // Ignore } } // Full resolution let resolved = null; try { resolved = await this.resolveArtistIdentity(rawName); } catch { // Ignore } return { input: rawName, normalized, cacheHit: cacheHit.rows.length > 0, exactMatch: exactMatch.rows[0] ?? null, fuzzyMatch: fuzzyMatch ? { ...fuzzyMatch.match, score: fuzzyMatch.score } : null, mbResult, resolved, }; } /** * Merge duplicate albums — albums with the same title (case-insensitive) that * should be a single row. This happens when: * - Compilation albums were scanned before the albumartist fix, creating * one album row per track artist instead of one under "Various Artists". * - Albums with slight title variations (whitespace, case) across files. * * Strategy (preferring the "best" row as keeper): * 1. Group by lower(title). * 2. Within each group, prefer rows with an MBID, then with artwork, then * with the most tracks, then oldest. * 3. Move all tracks from loser albums to the keeper. * 4. Move artwork/year/mbid from any loser onto the keeper if the keeper * lacks them (COALESCE-style — don't lose metadata). * 5. Delete the loser rows (ON DELETE CASCADE handles remaining refs). * * Also merges by MBID when two album rows share the same release-group MBID * (can happen when the release-group search wrote the same MBID to two * pre-existing rows before they were merged). * * Returns the number of albums merged away. */ async dedupAlbums(): Promise { let merged = 0; // --- Pass 1: merge by shared MBID --- const mbidDups = await this.pgClient.query( `SELECT mbid, array_agg(id ORDER BY CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END, release_date NULLS LAST, created_at ) AS ids FROM albums WHERE mbid IS NOT NULL GROUP BY mbid HAVING COUNT(*) > 1` ); for (const row of mbidDups.rows) { const [keepId, ...loserIds] = row.ids as string[]; for (const loserId of loserIds) { await this.mergeAlbum(keepId, loserId); merged++; } } // --- Pass 2: merge by same title (case-insensitive) --- // Group albums by lower(title) and merge within each group. We only merge // when the titles are truly the same album — the albumartist fix prevents // future duplicates, but existing data needs this cleanup. Prefer rows // with an MBID, then with artwork, then earliest release_date, then oldest. const titleDups = await this.pgClient.query( `SELECT lower(title) AS lower_title, array_agg(id ORDER BY CASE WHEN mbid IS NOT NULL THEN 0 ELSE 1 END, CASE WHEN artwork_id IS NOT NULL AND artwork_id <> '' THEN 0 ELSE 1 END, release_date NULLS LAST, created_at ) AS ids FROM albums GROUP BY lower(title) HAVING COUNT(*) > 1` ); for (const row of titleDups.rows) { const [keepId, ...loserIds] = row.ids as string[]; for (const loserId of loserIds) { await this.mergeAlbum(keepId, loserId); merged++; } } console.log(`[Enrich] Album dedup: merged ${merged} duplicate album(s)`); return merged; } /** * Merge one album into another: move tracks, fold metadata, delete the loser. */ private async mergeAlbum(keepId: string, loserId: string): Promise { // Fold any metadata the keeper is missing from the loser before moving // tracks (so we don't lose artwork/year/mbid/release_date when we delete). await this.pgClient.query( `UPDATE albums SET artwork_id = COALESCE(artwork_id, src.artwork_id), year = COALESCE(year, src.year), mbid = COALESCE(mbid, src.mbid), release_date = COALESCE(release_date, src.release_date) FROM (SELECT artwork_id, year, mbid, release_date FROM albums WHERE id = $2) AS src WHERE albums.id = $1`, [keepId, loserId] ); // Move tracks to the keeper. await this.pgClient.query( 'UPDATE tracks SET album_id = $1 WHERE album_id = $2', [keepId, loserId] ); // Delete the loser (ON DELETE CASCADE handles any remaining FK refs). await this.pgClient.query('DELETE FROM albums WHERE id = $1', [loserId]); console.log(`[Enrich] Album dedup: merged ${loserId} -> ${keepId}`); } }