1312 lines
48 KiB
TypeScript
1312 lines
48 KiB
TypeScript
import { Client as PgClient } from 'pg';
|
||
import {
|
||
MusicBrainzClient,
|
||
LastFmClient,
|
||
DiscogsClient,
|
||
LrcLibClient,
|
||
CoverArtClient,
|
||
WikimediaClient,
|
||
WikidataClient,
|
||
FanartClient,
|
||
TheAudioDbClient,
|
||
ITunesClient,
|
||
DeezerClient,
|
||
upscaleITunesArtwork,
|
||
} from './integrations/index.js';
|
||
import { AudioFeaturesService } from './audio-features.service.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;
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
private readonly audioFeatures: AudioFeaturesService;
|
||
|
||
constructor(private pgClient: PgClient) {
|
||
this.audioFeatures = new AudioFeaturesService(pgClient);
|
||
}
|
||
|
||
/**
|
||
* 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<void> {
|
||
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<TrackRow | null> {
|
||
const res = await this.pgClient.query<TrackRow>(
|
||
`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<string> {
|
||
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<void> {
|
||
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(
|
||
`INSERT INTO artists (name, canonical_name, sort_name, mbid, normalized_name)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
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, normalized]
|
||
);
|
||
|
||
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(
|
||
`INSERT INTO artists (name, canonical_name, sort_name, normalized_name)
|
||
VALUES ($1, $2, $3, $4)
|
||
RETURNING id`,
|
||
[rawName, rawName, sortName, normalized]
|
||
);
|
||
|
||
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<void> {
|
||
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<void> {
|
||
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<string | null> {
|
||
// 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);
|
||
}
|
||
|
||
// NOTE: Wikimedia Commons "by name" was previously the last fallback, but a
|
||
// blind File-namespace text search ("<name> 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<void> {
|
||
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<void> {
|
||
const track = await this.loadTrack(trackId);
|
||
if (!track) {
|
||
console.warn(`[Enrich] track not found: ${trackId}`);
|
||
return;
|
||
}
|
||
|
||
// Load enrichment settings. Default all to true (best-effort).
|
||
const settings = await this.loadSettings();
|
||
|
||
const summary: string[] = [];
|
||
const album = track.album_title ?? undefined;
|
||
|
||
// Merged genre/tag signals (name -> max weight) from MB + Last.fm.
|
||
const tagWeights = new Map<string, number>();
|
||
let recordingMatch: Awaited<
|
||
ReturnType<MusicBrainzClient['lookupRecording']>
|
||
> = 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().
|
||
|
||
// --- g. Audio features from embedded tags --------------------------------
|
||
if (settings.enrich_audio_analysis) {
|
||
try {
|
||
await this.audioFeatures.ensureSchema();
|
||
await this.audioFeatures.extractAndPersist(trackId);
|
||
summary.push('audio_features');
|
||
} catch (err) {
|
||
console.warn('[Enrich] Audio features step failed:', (err as Error).message);
|
||
}
|
||
} // enrich_audio_analysis
|
||
|
||
console.log(
|
||
`[Enrich] track ${trackId} enriched: ${
|
||
summary.length > 0 ? summary.join(', ') : 'nothing'
|
||
}`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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_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_genres','enrich_cover_art',
|
||
'enrich_lyrics','enrich_artist_similarity','enrich_audio_analysis')`
|
||
);
|
||
const map: Record<string, boolean> = {};
|
||
for (const r of rows.rows) {
|
||
map[r.key] = r.value === 'true';
|
||
}
|
||
return {
|
||
enrich_metadata: map.enrich_metadata ?? true,
|
||
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 enrich_metadata (same toggle the
|
||
* inline step used).
|
||
*/
|
||
async refreshArtistImage(artistId: string): Promise<void> {
|
||
const settings = await this.loadSettings();
|
||
if (!settings.enrich_metadata) return;
|
||
|
||
const res = await this.pgClient.query<{ name: string; canonical_name: string | null; mbid: string | null }>(
|
||
`SELECT name, canonical_name, mbid FROM artists WHERE id = $1`,
|
||
[artistId]
|
||
);
|
||
const artist = res.rows[0];
|
||
if (!artist) {
|
||
console.warn(`[Enrich] artist not found for image: ${artistId}`);
|
||
return;
|
||
}
|
||
|
||
const url = await this.getArtistImage(artistId, artist.mbid, artist.canonical_name ?? artist.name);
|
||
console.log(`[Enrich] artist image ${artistId}: ${url ? 'set' : 'none'}`);
|
||
}
|
||
|
||
/**
|
||
* 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<void> {
|
||
const settings = await this.loadSettings();
|
||
if (!settings.enrich_cover_art) return;
|
||
|
||
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;
|
||
}
|
||
if (album.artwork_id) return; // already has cover — nothing to do
|
||
|
||
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;
|
||
}
|
||
} 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;
|
||
}
|
||
} 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;
|
||
}
|
||
} 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;
|
||
}
|
||
} 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;
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.warn('[Enrich] album cover CAA release step failed:', (err as Error).message);
|
||
}
|
||
|
||
console.log(`[Enrich] album cover ${albumId}: none`);
|
||
}
|
||
|
||
async refreshArtistSimilarity(artistId: string): Promise<void> {
|
||
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<number> {
|
||
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<void> {
|
||
// 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}`);
|
||
}
|
||
}
|