d371bd97f3
The image proxy allowlist covered none of the hosts enrichment writes, so every album cover answered 403: 305 covers sit on coverartarchive.org (which 302s to archive.org and on to an ia*.us.archive.org node), 126 on mzstatic, 115 on dzcdn, 26 on discogs. Artist images were empty for a different reason — no working source. 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 photos. Deezer needs no auth and its host is already allowlisted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1346 lines
51 KiB
TypeScript
1346 lines
51 KiB
TypeScript
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<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(
|
||
// 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<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);
|
||
}
|
||
|
||
// 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 ("<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<EnrichmentJobOutcome> {
|
||
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<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().
|
||
|
||
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<string, boolean> = {};
|
||
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<EnrichmentJobOutcome> {
|
||
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<EnrichmentJobOutcome> {
|
||
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<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}`);
|
||
}
|
||
}
|