import type { Queryable } from './db.js'; import { MusicBrainzClient } from './integrations/musicbrainz.client.js'; import { normalizeForMatching } from './utils/fuzzy-match.js'; function generateSortName(name: string): string { const match = name.match(/^(The|A|An)\s+(.+)$/i); if (match) return `${match[2]}, ${match[1]}`; return name; } export class MbSpineWriter { constructor(private pgClient: Queryable) {} /** * Fetch full artist-credit for a recording MBID and write claims. * - First credit → "credited_main_on" * - Additional credits → "featured_on" * - Updates tracks.recording_mbid * - Resolves or stubs credited artists by MBID */ async writeRecordingClaims( recordingMbid: string, trackId: string, mbClient: MusicBrainzClient ): Promise { const recording = await mbClient.getRecording(recordingMbid); if (!recording || recording.artistCredit.length === 0) return 0; await this.pgClient.query( `UPDATE tracks SET recording_mbid = $1 WHERE id = $2 AND recording_mbid IS DISTINCT FROM $1`, [recordingMbid, trackId] ); let claimsWritten = 0; for (let i = 0; i < recording.artistCredit.length; i++) { const credit = recording.artistCredit[i]; const artistId = await this.resolveArtist( credit.artistMbid, credit.artistName, credit.creditName, ); if (!artistId) { console.warn( `[MbSpineWriter] Could not resolve artist for credit ${i} on recording ${recordingMbid}` ); continue; } const predicate = i === 0 ? 'credited_main_on' : 'featured_on'; const raw = credit.joinphrase ? JSON.stringify({ joinphrase: credit.joinphrase }) : null; await this.pgClient.query( `INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING`, ['track', trackId, predicate, 'artist', artistId, 'mb', 1.0, raw] ); claimsWritten++; } return claimsWritten; } /** * Fetch full artist-credit for a release-group MBID and write claims. * - First credit → "credited_main_on_album" * - Additional credits → "featured_on_album" * - Resolves or stubs credited artists by MBID */ async writeAlbumClaims( releaseGroupMbid: string, albumId: string, mbClient: MusicBrainzClient ): Promise { const releaseGroup = await mbClient.getReleaseGroup(releaseGroupMbid); if (!releaseGroup || releaseGroup.artistCredit.length === 0) return 0; let claimsWritten = 0; for (let i = 0; i < releaseGroup.artistCredit.length; i++) { const credit = releaseGroup.artistCredit[i]; const artistId = await this.resolveArtist( credit.artistMbid, credit.artistName, credit.creditName, ); if (!artistId) { console.warn( `[MbSpineWriter] Could not resolve artist for credit ${i} on release-group ${releaseGroupMbid}` ); continue; } const predicate = i === 0 ? 'credited_main_on_album' : 'featured_on_album'; const raw = credit.joinphrase ? JSON.stringify({ joinphrase: credit.joinphrase }) : null; await this.pgClient.query( `INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING`, ['artist', artistId, predicate, 'album', albumId, 'mb', 1.0, raw] ); claimsWritten++; } return claimsWritten; } /** * Fetch an artist's MB artist-relations and write structural claims: * - "member of" → "member_of" (subject = this artist, object = target group) * - "is alias of" / "is performance name of" / "is legal name of" → "alias_of" * * Direction handling: MB marks the relationship as forward when it points from * the queried artist to the target. For "is performance name of" (forward) * the queried artist is the alias of the target, so subject = queried artist, * object = target. For backward relations we flip subject/object. * * Collab/vocal/instrument ARs are artist-artist contributions rather than * identity/structure, and the spec maps them to track-level featured_on; we * skip them here to avoid over-claiming artist-artist edges. */ async writeArtistRelationClaims( artistMbid: string, artistId: string, mbClient: MusicBrainzClient ): Promise { const relations = await mbClient.getArtistRelations(artistMbid); if (relations.length === 0) return 0; const memberOfTypes = new Set(['member of', 'founder of', 'founder']); const aliasOfTypes = new Set(['is alias of', 'is performance name of', 'is legal name of']); let claimsWritten = 0; for (const rel of relations) { const isMemberOf = [...memberOfTypes].some(t => rel.type.toLowerCase() === t); const aliasMatch = [...aliasOfTypes].find(t => rel.type.toLowerCase() === t); if (!isMemberOf && !aliasMatch) continue; const targetArtistId = await this.resolveArtist( rel.targetMbid, rel.targetName, rel.targetName, ); if (!targetArtistId) { console.warn( `[MbSpineWriter] Could not resolve target artist for relation "${rel.type}" on artist ${artistMbid}` ); continue; } // Forward direction: subject = this artist, object = target. // Backward direction: the relation is phrased from target → subject, so // we flip subject/object to preserve the predicate's intended direction. let subjectId = artistId; let objectId = targetArtistId; if (rel.direction === 'backward') { subjectId = targetArtistId; objectId = artistId; } const predicate = aliasMatch ? 'alias_of' : 'member_of'; const raw = JSON.stringify({ mb_relation_type: rel.type, direction: rel.direction, }); await this.pgClient.query( `INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, raw) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING`, ['artist', subjectId, predicate, 'artist', objectId, 'mb', 1.0, raw] ); claimsWritten++; } return claimsWritten; } /** * Resolve an MB artist to a local artists.id. Priority: * 1. Match by mbid (UUID column) * 2. Match by normalized credit name * 3. Create a stub row */ private async resolveArtist( mbid: string, artistName: string, creditName: string, ): Promise { const existing = await this.pgClient.query<{ id: string }>( `SELECT id FROM artists WHERE mbid = $1`, [mbid] ); if (existing.rows.length > 0) return existing.rows[0].id; const normalized = normalizeForMatching(creditName); const nameMatch = await this.pgClient.query<{ id: string }>( `SELECT id FROM artists WHERE normalized_name = $1`, [normalized] ); if (nameMatch.rows.length > 0) { await this.pgClient.query( `UPDATE artists SET mbid = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [mbid, nameMatch.rows[0].id] ); return nameMatch.rows[0].id; } const sortName = generateSortName(artistName); const result = await this.pgClient.query<{ id: string }>( `INSERT INTO artists (name, canonical_name, sort_name, mbid) VALUES ($1, $2, $3, $4) ON CONFLICT (mbid) WHERE mbid IS NOT NULL DO UPDATE SET name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP RETURNING id`, [creditName, artistName, sortName, mbid] ); return result.rows[0]?.id ?? null; } }