From ab7f0141dc425adaec06782513f8de0aa9c909f9 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 22 Jun 2026 19:07:09 -0700 Subject: [PATCH] Add basic MEAD artist profile updates --- fixtures/mead_artist_update.xml | 38 +++ src/db.ts | 25 +- src/db/artistProfileUpdateRepo.ts | 127 ++++++++++ src/db/migrations.ts | 28 +++ src/db/userRepo.ts | 8 + src/parseDelivery.ts | 395 +++++++++++++++++++++++++++++- src/parseMead.test.ts | 273 +++++++++++++++++++++ src/publishRelease.test.ts | 139 +++++++++-- src/publishRelease.ts | 139 ++++++++++- 9 files changed, 1142 insertions(+), 30 deletions(-) create mode 100644 fixtures/mead_artist_update.xml create mode 100644 src/db/artistProfileUpdateRepo.ts create mode 100644 src/parseMead.test.ts diff --git a/fixtures/mead_artist_update.xml b/fixtures/mead_artist_update.xml new file mode 100644 index 0000000..91ca8d6 --- /dev/null +++ b/fixtures/mead_artist_update.xml @@ -0,0 +1,38 @@ + + + + mead-artist-update-1 + 2026-06-22T12:00:00Z + + + + + P1 + + + DJ Theo + + + + + + + DJ Theo Official + + + true + + + Oakland producer and DJ building vivid left-field dance records. + + + + resources/Image_001_001.jpg + + + + + + diff --git a/src/db.ts b/src/db.ts index 10c2d79..13c26e5 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,5 +1,6 @@ -import { DDEXRelease } from './parseDelivery' +import type { DDEXArtistProfileUpdate, DDEXRelease } from './parseDelivery' +export { artistProfileUpdateRepo } from './db/artistProfileUpdateRepo' export { assetRepo } from './db/assetRepo' export { isClearedRepo } from './db/isClearedRepo' export { releaseRepo } from './db/releaseRepo' @@ -34,6 +35,13 @@ export enum ReleaseProcessingStatus { Deleted = 'Deleted', } +export enum ArtistProfileUpdateStatus { + Blocked = 'Blocked', + PublishPending = 'PublishPending', + Published = 'Published', + Failed = 'Failed', +} + export type ReleaseRow = DDEXRelease & { source: string key: string @@ -60,6 +68,21 @@ export type ReleaseRow = DDEXRelease & { mediaDeletedAt?: string } +export type ArtistProfileUpdateRow = DDEXArtistProfileUpdate & { + source: string + key: string + xmlUrl: string + messageTimestamp: string + status: ArtistProfileUpdateStatus + createdAt: string + updatedAt: string + publishedAt?: string + blockHash?: string + blockNumber?: number + lastPublishError: string + publishErrorCount: number +} + export type S3MarkerRow = { bucket: string marker: string diff --git a/src/db/artistProfileUpdateRepo.ts b/src/db/artistProfileUpdateRepo.ts new file mode 100644 index 0000000..2d4672f --- /dev/null +++ b/src/db/artistProfileUpdateRepo.ts @@ -0,0 +1,127 @@ +import { ArtistProfileUpdateRow, ArtistProfileUpdateStatus } from '../db' +import { DDEXArtistProfileUpdate } from '../parseDelivery' +import { omitEmpty } from '../util' +import { ifdef, pgUpdate, pgUpsert, sql } from './sql' + +type FindArtistProfileUpdateParams = { + pendingPublish?: boolean + source?: string + limit?: number +} + +export const artistProfileUpdateRepo = { + chooseKey(source: string, xmlUrl: string, update: DDEXArtistProfileUpdate) { + const id = + update.audiusUser || + update.artistHandle || + update.artistName || + update.partyRef + if (!id) { + const msg = `failed to chooseArtistProfileUpdateKey: ${JSON.stringify( + update + )}` + console.log(msg) + throw new Error(msg) + } + return [source, xmlUrl, id].join(':') + }, + + async all(params?: FindArtistProfileUpdateParams) { + params ||= {} + const rows: ArtistProfileUpdateRow[] = await sql` + select * from artist_profile_updates + where 1=1 + + ${ifdef( + params.pendingPublish, + sql` + and status in ( + ${ArtistProfileUpdateStatus.PublishPending}, + ${ArtistProfileUpdateStatus.Failed} + ) + and "publishErrorCount" < 5 + ` + )} + + ${ifdef(params.source, sql` and "source" = ${params.source!} `)} + + order by "messageTimestamp" asc + + ${ifdef(params.limit, sql` limit ${params.limit!} `)} + ` + + return rows + }, + + async get(key: string) { + const rows = + await sql`select * from artist_profile_updates where "key" = ${key}` + const row = rows[0] + if (!row) return + return row as ArtistProfileUpdateRow + }, + + async update(r: Partial) { + await pgUpdate('artist_profile_updates', 'key', r) + }, + + async upsert( + source: string, + xmlUrl: string, + messageTimestamp: string, + update: DDEXArtistProfileUpdate + ) { + const key = artistProfileUpdateRepo.chooseKey(source, xmlUrl, update) + const prior = await artistProfileUpdateRepo.get(key) + + if ( + prior && + (prior.messageTimestamp > messageTimestamp || + (prior.messageTimestamp == messageTimestamp && + prior.status == ArtistProfileUpdateStatus.Published)) + ) { + console.log(`skipping ${xmlUrl} because ${key} is newer`) + return + } + + const status = update.problems.length + ? ArtistProfileUpdateStatus.Blocked + : ArtistProfileUpdateStatus.PublishPending + + const data = { + source, + key, + status, + xmlUrl, + messageTimestamp, + updatedAt: new Date().toISOString(), + ...update, + } as Partial + + await pgUpsert('artist_profile_updates', 'key', omitEmpty(data)) + }, + + async addPublishError(key: string, err: Error) { + const status = ArtistProfileUpdateStatus.Failed + const errText = err.stack || err.toString() + await sql` + update artist_profile_updates set + status=${status}, + "lastPublishError"=${errText}, + "publishErrorCount" = "publishErrorCount" + 1 + where "key" = ${key} + ` + }, + + async addPublishBlock(key: string, err: Error) { + const status = ArtistProfileUpdateStatus.Blocked + const errText = err.stack || err.toString() + await sql` + update artist_profile_updates set + status=${status}, + "lastPublishError"=${errText}, + "publishErrorCount" = "publishErrorCount" + 1 + where "key" = ${key} + ` + }, +} diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 6d69d25..ae2f8b1 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -156,6 +156,34 @@ const steps = [ sql`ALTER TABLE releases ADD COLUMN IF NOT EXISTS "plannedEntityId" text;`, sql`ALTER TABLE releases ADD COLUMN IF NOT EXISTS "plannedTrackIds" jsonb;`, sql`ALTER TABLE releases ADD COLUMN IF NOT EXISTS "partialTrackIds" jsonb;`, + + sql` + create table if not exists artist_profile_updates ( + "source" text not null, + "key" text primary key, + "xmlUrl" text not null, + "messageTimestamp" text, + "partyRef" text, + "artistName" text, + "artistHandle" text, + "audiusUser" text, + "displayName" text, + "bio" text, + "profilePicture" jsonb, + "coverArt" jsonb, + "problems" jsonb, + "status" text not null, + "blockHash" text, + "blockNumber" integer, + "publishedAt" timestamptz, + "publishErrorCount" integer default 0, + "lastPublishError" text, + "createdAt" timestamptz DEFAULT CURRENT_TIMESTAMP, + "updatedAt" timestamptz + ); + `, + + sql`CREATE INDEX IF NOT EXISTS idx_artist_profile_updates_pending ON artist_profile_updates ("status", "publishErrorCount");`, ] // poor man's migrate diff --git a/src/db/userRepo.ts b/src/db/userRepo.ts index 6b4d301..6ed7e8e 100644 --- a/src/db/userRepo.ts +++ b/src/db/userRepo.ts @@ -23,6 +23,14 @@ export const userRepo = { return users[0] }, + async findByHandleAndApiKey(handle: string, apiKey: string) { + const users: UserRow[] = await sql` + select * from users + where lower(handle) = lower(${handle}) and "apiKey" = ${apiKey} + ` + return users[0] + }, + async upsert(user: UserRow) { await sql` insert into users ${sql(user)} diff --git a/src/parseDelivery.ts b/src/parseDelivery.ts index c8f24be..2ef61eb 100644 --- a/src/parseDelivery.ts +++ b/src/parseDelivery.ts @@ -9,12 +9,12 @@ import { acknowledgeReleaseFailure, acknowledgeReleaseSuccess, } from './acknowledgement' -import { releaseRepo, userRepo, xmlRepo } from './db' +import { artistProfileUpdateRepo, releaseRepo, userRepo, xmlRepo } from './db' import { sources } from './sources' import { genreMapping } from './genreMapping' import { lowerAscii, omitEmpty } from './util' -type CH = cheerio.Cheerio +type CH = cheerio.Cheerio export type DDEXReleaseIds = { party_id?: string @@ -33,6 +33,352 @@ export type DDEXReleaseIds = { proprietary_id?: string } +// +// parse MEAD artist profile updates +// +async function parseMeadXml(source: string, $: cheerio.CheerioAPI) { + const sourceConfig = sources.findByName(source) + const contexts = collectMeadPartyInformation($) + + const work = contexts.map(async ({ $subject, $info, index }) => { + const partyNames = extractPartyNames($subject) + const pseudonyms = extractPseudonyms($info) + const explicitDisplayName = + firstLocalText($info, 'DisplayName') || + firstLocalText($info, 'DisplayArtistName') || + pseudonyms.find((p) => p.isOfficial)?.name || + pseudonyms[0]?.name + const artistName = partyNames[0] || explicitDisplayName + const partyRef = + firstLocalText($subject, 'PartyReference') || + firstLocalText($info, 'PartyReference') || + `party-${index + 1}` + const ids = extractMeadPartyIds($subject) + const artistHandle = ids.artistHandle + const audiusUser = + (ids.audiusUser && + sourceConfig && + (await userRepo.findByIdAndApiKey(ids.audiusUser, sourceConfig.ddexKey)) + ?.id) || + (artistHandle && + sourceConfig && + ( + await userRepo.findByHandleAndApiKey( + artistHandle, + sourceConfig.ddexKey + ) + )?.id) || + (sourceConfig && + (await userRepo.match(sourceConfig.ddexKey, [ + ...partyNames, + ...pseudonyms.map((p) => p.name), + ...(explicitDisplayName ? [explicitDisplayName] : []), + ]))) + + const images = findByLocalName($info, 'Image') + .toArray() + .map((el, imageIndex) => parseMeadImage($(el), imageIndex)) + .filter((image): image is DDEXResource & { imageType?: string } => + Boolean(image) + ) + const coverArt = images.find((image) => isCoverImageType(image.imageType)) + const profilePicture = + images.find((image) => isProfileImageType(image.imageType)) || + images.find((image) => image.ref != coverArt?.ref) || + images[0] + + const update: DDEXArtistProfileUpdate = { + partyRef, + artistName, + artistHandle, + audiusUser, + displayName: explicitDisplayName || artistName, + bio: extractBiography($info), + profilePicture, + coverArt, + problems: [], + } + + if ( + !update.displayName && + !update.bio && + !update.profilePicture && + !update.coverArt + ) { + return + } + + if (!sourceConfig) { + update.problems.push('UnknownSource') + } + if (!update.audiusUser) { + update.problems.push('NoAudiusUser') + } + + return update + }) + + return (await Promise.all(work)).filter( + (update): update is DDEXArtistProfileUpdate => Boolean(update) + ) +} + +type MeadPartyInformationContext = { + $subject: CH + $info: CH + index: number +} + +function collectMeadPartyInformation( + $: cheerio.CheerioAPI +): MeadPartyInformationContext[] { + const contexts: MeadPartyInformationContext[] = [] + const entries = findByLocalName($.root(), 'Entry').toArray() + + for (const [index, entry] of entries.entries()) { + const $entry = $(entry) + const $info = findByLocalName($entry, 'PartyInformation').first() + if (!$info.length) continue + + const $subject = + childrenByLocalName($entry, 'Party').first().length > 0 + ? childrenByLocalName($entry, 'Party').first() + : findByLocalName($entry, 'PartySummary').first() + + contexts.push({ + $subject: $subject.length ? $subject : $entry, + $info, + index, + }) + } + + if (contexts.length) return contexts + + findByLocalName($.root(), 'PartyInformation').each((index, el) => { + const $info = $(el) + const $subject = + findByLocalName($info, 'PartySummary').first().length > 0 + ? findByLocalName($info, 'PartySummary').first() + : findByLocalName($info, 'Party').first() + + contexts.push({ + $subject: $subject.length ? $subject : $info, + $info, + index, + }) + }) + + return contexts +} + +function extractPartyNames($el: CH) { + return uniqueStrings( + findByLocalName($el, 'PartyName') + .toArray() + .map((el) => nameText($elCheerio($el, el))) + .filter(Boolean) + ) +} + +function extractPseudonyms($info: CH) { + return findByLocalName($info, 'Pseudonym') + .toArray() + .map((el) => { + const $pseudonym = $elCheerio($info, el) + const $name = findByLocalName($pseudonym, 'Name').first() + return { + name: nameText($name.length ? $name : $pseudonym), + isOfficial: parseXmlBool(firstLocalText($pseudonym, 'IsOfficial')), + } + }) + .filter((p) => p.name) +} + +function extractBiography($info: CH) { + const biographies = findByLocalName($info, 'Biography').toArray() + for (const biography of biographies) { + const $biography = $elCheerio($info, biography) + const text = findByLocalName($biography, 'Text') + .toArray() + .map((el) => normalizeWhitespace($elCheerio($biography, el).text())) + .find(Boolean) + if (text) return text + } +} + +function extractMeadPartyIds($subject: CH) { + const ids: { audiusUser?: string; artistHandle?: string } = {} + + const directAudiusUser = + firstLocalText($subject, 'AudiusUserId') || + firstLocalText($subject, 'AudiusUser') || + firstLocalText($subject, 'AudiusProfileId') + if (directAudiusUser) ids.audiusUser = directAudiusUser + + const directHandle = + firstLocalText($subject, 'AudiusHandle') || + firstLocalText($subject, 'ArtistHandle') || + firstLocalText($subject, 'Handle') + if (directHandle) ids.artistHandle = directHandle + + findByLocalName($subject, 'ProprietaryId').each((_, el) => { + const $id = $elCheerio($subject, el) + const namespace = normalizeWhitespace( + [ + $id.attr('Namespace'), + $id.attr('namespace'), + firstLocalText($id, 'Namespace'), + ] + .filter(Boolean) + .join(' ') + ).toLowerCase() + const value = + firstLocalText($id, 'Identifier') || + firstLocalText($id, 'PartyId') || + normalizeWhitespace($id.clone().children().remove().end().text()) || + normalizeWhitespace($id.text()) + + if (!value) return + if (/audius.*(user|profile)|user.*audius/.test(namespace)) { + ids.audiusUser ||= value + } else if (/audius.*handle|handle.*audius/.test(namespace)) { + ids.artistHandle ||= value + } + }) + + return ids +} + +function parseMeadImage( + $image: CH, + index: number +): (DDEXResource & { imageType?: string }) | undefined { + const $file = findByLocalName($image, 'File').first() + const uri = + firstLocalText($file, 'URI') || + firstLocalText($file, 'Uri') || + firstLocalText($file, 'URL') || + firstLocalText($file, 'Url') + const fromUri = splitResourceUri(uri) + const filePath = firstLocalText($file, 'FilePath') || fromUri.filePath || '' + const fileName = firstLocalText($file, 'FileName') || fromUri.fileName || '' + + if (!fileName && !uri) return + + const ref = + firstLocalText($image, 'ResourceReference') || + firstLocalText($file, 'FileReference') || + `mead-image-${index + 1}` + const imageTypeEl = findByLocalName($image, 'ImageType').first() + const imageType = + imageTypeEl.attr('UserDefinedValue') || + imageTypeEl.attr('Value') || + normalizeWhitespace(imageTypeEl.text()) + + return omitEmpty({ + ref, + filePath, + fileName, + uri, + imageType, + }) as DDEXResource & { imageType?: string } +} + +function isProfileImageType(imageType?: string) { + if (!imageType) return false + return /artist|profile|portrait|avatar|photo|headshot/i.test(imageType) +} + +function isCoverImageType(imageType?: string) { + if (!imageType) return false + return /cover|banner|header|background/i.test(imageType) +} + +function splitResourceUri(uri?: string) { + if (!uri) return {} + const clean = uri.trim() + if (/^https?:\/\//i.test(clean)) { + try { + const url = new URL(clean) + const path = url.pathname + return { + uri: clean, + filePath: '', + fileName: path.substring(path.lastIndexOf('/') + 1) || clean, + } + } catch { + return { uri: clean, filePath: '', fileName: clean } + } + } + + const lastSlashIndex = clean.lastIndexOf('/') + if (lastSlashIndex === -1) { + return { uri: clean, filePath: '', fileName: clean } + } + return { + uri: clean, + filePath: clean.substring(0, lastSlashIndex + 1), + fileName: clean.substring(lastSlashIndex + 1), + } +} + +function $elCheerio($context: CH, el: Element): CH { + const make = ($context as any)._make + if (make) return make.call($context, el) as CH + + const $ = cheerio.load([el] as any, { xmlMode: true }) + return $.root().children().first() as CH +} + +function localName(rawName?: string) { + return (rawName || '').split(':').pop() || '' +} + +function elementLocalName(el: Element) { + return localName((el as any).name || (el as any).tagName) +} + +function findByLocalName($el: CH, tagName: string) { + return $el.find('*').filter((_, el) => elementLocalName(el) == tagName) +} + +function childrenByLocalName($el: CH, tagName: string) { + return $el.children().filter((_, el) => elementLocalName(el) == tagName) +} + +function firstLocalText($el: CH, tagName: string) { + return normalizeWhitespace(findByLocalName($el, tagName).first().text()) +} + +function normalizeWhitespace(text?: string) { + return (text || '').replace(/\s+/g, ' ').trim() +} + +function nameText($name: CH) { + return ( + firstLocalText($name, 'FullName') || + firstLocalText($name, 'Name') || + normalizeWhitespace($name.text()) + ) +} + +function parseXmlBool(text?: string) { + const normalized = normalizeWhitespace(text).toLowerCase() + return normalized == 'true' || normalized == '1' +} + +function uniqueStrings(values: Array) { + const seen = new Set() + const result: string[] = [] + for (const value of values) { + const normalized = normalizeWhitespace(value) + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + result.push(normalized) + } + return result +} + export type DDEXPurgeRelease = { releaseIds: DDEXReleaseIds } @@ -81,6 +427,19 @@ export type DDEXResource = { ref: string filePath: string fileName: string + uri?: string +} + +export type DDEXArtistProfileUpdate = { + partyRef?: string + artistName?: string + artistHandle?: string + audiusUser?: string + displayName?: string + bio?: string + profilePicture?: DDEXResource + coverArt?: DDEXResource + problems: string[] } export type DDEXSoundRecording = { @@ -195,14 +554,24 @@ export async function parseDdexXml( const $ = cheerio.load(xmlText, { xmlMode: true }) const messageTimestamp = $('MessageCreatedDateTime').first().text() - const rawTagName = $.root().children().first().prop('name') + const rawTagName = $.root().children().first().prop('name') || '' + const rootTagName = localName(rawTagName) const tagName = [ 'NewReleaseMessage', 'PurgeReleaseMessage', 'ManifestMessage', - ].find((n) => rawTagName.includes(n)) + 'MeadMessage', + 'AuxiliaryInformationMessage', + 'Feed', + ].find((n) => rootTagName == n || rawTagName.includes(n)) const isUpdate = $('UpdateIndicator').text() == 'UpdateMessage' const messageId = $('MessageId').text() + const isMead = + tagName == 'MeadMessage' || + tagName == 'AuxiliaryInformationMessage' || + (tagName == 'Feed' && + findByLocalName($.root(), 'PartyInformation').length > 0) || + findByLocalName($.root(), 'PartyInformationList').length > 0 // Detect DDEX version const isDdex40 = xmlText.includes('http://ddex.net/xml/ern/4') @@ -247,6 +616,17 @@ export async function parseDdexXml( } return releases + } else if (isMead) { + const artistProfileUpdates = await parseMeadXml(source, $) + for (const update of artistProfileUpdates) { + await artistProfileUpdateRepo.upsert( + source, + xmlUrl, + messageTimestamp, + update + ) + } + return artistProfileUpdates } else { console.log('unknown tagname', tagName) } @@ -901,8 +1281,7 @@ async function parseReleaseXml( audiusDealType: 'PayGated', forStream: true, forDownload: true, - priceUsd: - release.soundRecordings.length > 1 ? 5.0 : 1.0, + priceUsd: release.soundRecordings.length > 1 ? 5.0 : 1.0, validityStartDate: new Date().toISOString(), } release.deals = [defaultDeal] @@ -913,7 +1292,9 @@ async function parseReleaseXml( // inherit genre from sound recordings when release has none (Volume/ERN 382 style) if (!release.audiusGenre && release.soundRecordings.length) { - const firstWithGenre = release.soundRecordings.find((sr) => sr.audiusGenre) + const firstWithGenre = release.soundRecordings.find( + (sr) => sr.audiusGenre + ) if (firstWithGenre) { release.genre = firstWithGenre.genre release.subGenre = firstWithGenre.subGenre diff --git a/src/parseMead.test.ts b/src/parseMead.test.ts new file mode 100644 index 0000000..bc0403b --- /dev/null +++ b/src/parseMead.test.ts @@ -0,0 +1,273 @@ +import { beforeAll, expect, test } from 'vitest' + +import { + ArtistProfileUpdateStatus, + artistProfileUpdateRepo, + userRepo, +} from './db' +import { pgMigrate } from './db/migrations' +import { sql } from './db/sql' +import { + DDEXArtistProfileUpdate, + parseDdexXml, + parseDdexXmlFile, +} from './parseDelivery' +import { sources } from './sources' + +beforeAll(async () => { + await pgMigrate() + sources.load('./fixtures/sources.test.json') +}) + +test('parses MEAD artist profile updates', async () => { + await sql` + delete from artist_profile_updates + where "xmlUrl" = ${'fixtures/mead_artist_update.xml'} + ` + await userRepo.upsert({ + apiKey: 'crudTestKey', + id: 'djtheo', + handle: 'djtheo', + name: 'DJ Theo', + createdAt: new Date(), + }) + + const updates = (await parseDdexXmlFile( + 'crudTest', + 'fixtures/mead_artist_update.xml' + )) as DDEXArtistProfileUpdate[] + + expect(updates).toHaveLength(1) + expect(updates[0]).toMatchObject({ + partyRef: 'P1', + artistName: 'DJ Theo', + audiusUser: 'djtheo', + displayName: 'DJ Theo Official', + bio: 'Oakland producer and DJ building vivid left-field dance records.', + profilePicture: { + ref: 'mead-image-1', + filePath: 'resources/', + fileName: 'Image_001_001.jpg', + }, + problems: [], + }) + + const key = artistProfileUpdateRepo.chooseKey( + 'crudTest', + 'fixtures/mead_artist_update.xml', + updates[0] + ) + const row = await artistProfileUpdateRepo.get(key) + expect(row).toMatchObject({ + key, + status: ArtistProfileUpdateStatus.PublishPending, + audiusUser: 'djtheo', + displayName: 'DJ Theo Official', + }) +}) + +test('MEAD sender example: target an authorized artist by Audius user id', async () => { + await userRepo.upsert({ + apiKey: 'crudTestKey', + id: 'artist-user-1', + handle: 'artistone', + name: 'Artist One', + createdAt: new Date(), + }) + + const xmlUrl = 'fixtures/sender_example_user_id_mead.xml' + await sql`delete from artist_profile_updates where "xmlUrl" = ${xmlUrl}` + + const meadXml = ` + + + sender-example-user-id + 2026-06-22T13:00:00Z + + + + + P-ARTIST-1 + + artist-user-1 + + + + Artist One + + + + + + + Artist One Display + + + true + + + Short artist bio from a standalone MEAD update. + + + IMG-PROFILE + + images/profile.jpg + + + + + +` + + const updates = (await parseDdexXml( + 'crudTest', + xmlUrl, + meadXml + )) as DDEXArtistProfileUpdate[] + + expect(updates).toHaveLength(1) + expect(updates[0]).toMatchObject({ + partyRef: 'P-ARTIST-1', + artistName: 'Artist One', + audiusUser: 'artist-user-1', + displayName: 'Artist One Display', + bio: 'Short artist bio from a standalone MEAD update.', + profilePicture: { + ref: 'IMG-PROFILE', + filePath: 'images/', + fileName: 'profile.jpg', + }, + problems: [], + }) + + const key = artistProfileUpdateRepo.chooseKey('crudTest', xmlUrl, updates[0]) + await expect(artistProfileUpdateRepo.get(key)).resolves.toMatchObject({ + status: ArtistProfileUpdateStatus.PublishPending, + audiusUser: 'artist-user-1', + }) +}) + +test('MEAD sender example: send a feed entry and target by authorized Audius handle', async () => { + await userRepo.upsert({ + apiKey: 'crudTestKey', + id: 'artist-user-2', + handle: 'artisttwo', + name: 'Artist Two', + createdAt: new Date(), + }) + + const xmlUrl = 'fixtures/sender_example_feed_handle_mead.xml' + await sql`delete from artist_profile_updates where "xmlUrl" = ${xmlUrl}` + + const meadFeedXml = ` + + + sender-example-feed-handle + 2026-06-22T14:00:00Z + + + + P-ARTIST-2 + + artisttwo + + + + Artist Two + + + + + Artist Two Deluxe + + Bio update sent as an entry in a larger MEAD feed. + + + + https://cdn.example.test/artist-two/profile.png + + + + + + images/artist-two-banner.jpg + + + + + +` + + const updates = (await parseDdexXml( + 'crudTest', + xmlUrl, + meadFeedXml + )) as DDEXArtistProfileUpdate[] + + expect(updates).toHaveLength(1) + expect(updates[0]).toMatchObject({ + partyRef: 'P-ARTIST-2', + artistName: 'Artist Two', + artistHandle: 'artisttwo', + audiusUser: 'artist-user-2', + displayName: 'Artist Two Deluxe', + bio: 'Bio update sent as an entry in a larger MEAD feed.', + profilePicture: { + uri: 'https://cdn.example.test/artist-two/profile.png', + fileName: 'profile.png', + }, + coverArt: { + filePath: 'images/', + fileName: 'artist-two-banner.jpg', + }, + problems: [], + }) +}) + +test('MEAD sender example: unmatched artists are blocked until the source is authorized', async () => { + const xmlUrl = 'fixtures/sender_example_unmatched_mead.xml' + await sql`delete from artist_profile_updates where "xmlUrl" = ${xmlUrl}` + + const meadXml = ` + + + sender-example-unmatched + 2026-06-22T15:00:00Z + + + + + P-UNKNOWN + + + Unmatched Artist + + + + + This should not publish until the artist grants the source app. + + + +` + + const updates = (await parseDdexXml( + 'crudTest', + xmlUrl, + meadXml + )) as DDEXArtistProfileUpdate[] + + expect(updates).toHaveLength(1) + expect(updates[0]).toMatchObject({ + partyRef: 'P-UNKNOWN', + artistName: 'Unmatched Artist', + bio: 'This should not publish until the artist grants the source app.', + problems: ['NoAudiusUser'], + }) + + const key = artistProfileUpdateRepo.chooseKey('crudTest', xmlUrl, updates[0]) + await expect(artistProfileUpdateRepo.get(key)).resolves.toMatchObject({ + status: ArtistProfileUpdateStatus.Blocked, + problems: ['NoAudiusUser'], + }) +}) diff --git a/src/publishRelease.test.ts b/src/publishRelease.test.ts index d6fc296..a4fa24c 100644 --- a/src/publishRelease.test.ts +++ b/src/publishRelease.test.ts @@ -1,24 +1,48 @@ import { afterEach, expect, test, vi } from 'vitest' -const { assetRepo, getSdk, publogRepo, readAssetWithCaching, releaseRepo } = - vi.hoisted(() => ({ - assetRepo: { - get: vi.fn(), - }, - getSdk: vi.fn(), - publogRepo: { - log: vi.fn(), - }, - readAssetWithCaching: vi.fn(), - releaseRepo: { - update: vi.fn(), - }, - })) +const { + artistProfileUpdateRepo, + assetRepo, + getSdk, + publogRepo, + readAssetWithCaching, + releaseRepo, + userRepo, +} = vi.hoisted(() => ({ + artistProfileUpdateRepo: { + all: vi.fn(), + update: vi.fn(), + addPublishBlock: vi.fn(), + addPublishError: vi.fn(), + }, + assetRepo: { + get: vi.fn(), + }, + getSdk: vi.fn(), + publogRepo: { + log: vi.fn(), + }, + readAssetWithCaching: vi.fn(), + releaseRepo: { + update: vi.fn(), + }, + userRepo: { + findByIdAndApiKey: vi.fn(), + upsert: vi.fn(), + }, +})) vi.mock('./db', () => ({ + artistProfileUpdateRepo, assetRepo, releaseRepo, - userRepo: {}, + userRepo, + ArtistProfileUpdateStatus: { + Blocked: 'Blocked', + PublishPending: 'PublishPending', + Published: 'Published', + Failed: 'Failed', + }, ReleaseProcessingStatus: { Blocked: 'Blocked', PublishPending: 'PublishPending', @@ -45,6 +69,7 @@ import { deleteAlbumTracks, fetchAlbumTrackIds, publishRelease, + publishArtistProfileUpdate, updateAlbum, updateTrack, } from './publishRelease' @@ -300,6 +325,84 @@ test('updateAlbum sends the latest cover art file', async () => { expect(releaseUpdate).not.toHaveProperty('transactionHash') }) +test('publishArtistProfileUpdate updates Audius profile metadata and image', async () => { + const profileBuffer = Buffer.from('profile image') + const updateUserMock = vi.fn().mockResolvedValue({ + blockHash: '0xprofile', + blockNumber: 33, + }) + readAssetWithCaching.mockResolvedValue(profileBuffer) + getSdk.mockReturnValue({ + users: { + updateUser: updateUserMock, + }, + }) + userRepo.findByIdAndApiKey.mockResolvedValue({ + apiKey: source.ddexKey, + id: 'user-1', + handle: 'artist', + name: 'Artist', + createdAt: new Date(), + }) + + await publishArtistProfileUpdate(source, { + key: 'artist-update-1', + source: source.name, + xmlUrl: 's3://bucket/mead.xml', + messageTimestamp: '2026-06-22T12:00:00Z', + status: 'PublishPending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + publishErrorCount: 0, + lastPublishError: '', + audiusUser: 'user-1', + artistName: 'Artist', + displayName: 'Artist Official', + bio: 'A compact artist bio.', + profilePicture: { + ref: 'profile-image', + filePath: 'images/', + fileName: 'profile.jpg', + }, + problems: [], + } as any) + + expect(readAssetWithCaching).toHaveBeenCalledWith( + 's3://bucket/mead.xml', + 'images/', + 'profile.jpg' + ) + expect(updateUserMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'user-1', + userId: 'user-1', + metadata: { + name: 'Artist Official', + bio: 'A compact artist bio.', + }, + profilePictureFile: expect.objectContaining({ + buffer: profileBuffer, + name: 'profile.jpg', + }), + }) + ) + expect(artistProfileUpdateRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'artist-update-1', + status: 'Published', + publishedAt: expect.any(String), + blockHash: '0xprofile', + blockNumber: 33, + }) + ) + expect(userRepo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'user-1', + name: 'Artist Official', + }) + ) +}) + test('publishRelease persists album track ids after each track publish', async () => { mockReleaseAssets() const plannedTrackId1 = encodeId(101) @@ -496,9 +599,9 @@ test('publishRelease does not mark albums published without a response id', asyn }, }) - await expect(publishRelease(source, releaseRow, albumRelease())).rejects.toThrow( - 'album publish response missing playlistId' - ) + await expect( + publishRelease(source, releaseRow, albumRelease()) + ).rejects.toThrow('album publish response missing playlistId') expect(releaseRepo.update).not.toHaveBeenLastCalledWith( expect.objectContaining({ diff --git a/src/publishRelease.ts b/src/publishRelease.ts index c77d74c..efb69cb 100644 --- a/src/publishRelease.ts +++ b/src/publishRelease.ts @@ -1,7 +1,4 @@ -import type { - CreateAlbumMetadata, - UploadTrackRequest, -} from '@audius/sdk' +import type { CreateAlbumMetadata, UploadTrackRequest } from '@audius/sdk' import { fromBuffer as fileTypeFromBuffer } from 'file-type' import Web3 from 'web3' import { @@ -10,13 +7,18 @@ import { publishToClaimableAccount, } from './claimable/createUserPublish' import { + ArtistProfileUpdateRow, + ArtistProfileUpdateStatus, ReleaseProcessingStatus, ReleaseRow, + artistProfileUpdateRepo, assetRepo, releaseRepo, + userRepo, } from './db' import { publogRepo } from './db/publogRepo' import { + DDEXArtistProfileUpdate, DDEXContributor, DDEXRelease, DDEXResource, @@ -63,6 +65,8 @@ export const DEFAULT_ALBUM_DEAL: DealPayGated = { } export async function publishValidPendingReleases() { + await publishValidPendingArtistProfileUpdates() + const rows = await releaseRepo.all({ pendingPublish: true }) if (!rows.length) return @@ -119,6 +123,95 @@ export async function publishValidPendingReleases() { } } +export async function publishValidPendingArtistProfileUpdates() { + const rows = await artistProfileUpdateRepo.all({ pendingPublish: true }) + if (!rows.length) return + + for (const row of rows) { + const source = sources.findByName(row.source) + if (!source) { + await artistProfileUpdateRepo.addPublishBlock( + row.key, + new Error(`missing source: ${row.source}`) + ) + continue + } + + try { + await publishArtistProfileUpdate(source, row) + } catch (e: any) { + console.log('failed to publish artist profile update', row.key, e) + await artistProfileUpdateRepo.addPublishError(row.key, e) + } + } +} + +export async function publishArtistProfileUpdate( + source: SourceConfig, + row: ArtistProfileUpdateRow | DDEXArtistProfileUpdate +) { + if (!row.audiusUser) { + throw new Error(`audiusUser is required for artist profile update`) + } + + const metadata: Record = {} + if (row.displayName) { + metadata.name = row.displayName + } + if (row.bio) { + if (row.bio.length > 256) { + throw new Error(`MEAD bio must be 256 characters or fewer`) + } + metadata.bio = row.bio + } + + const profilePictureFile = row.profilePicture + ? await resolveArtistProfileAssetFile(row, row.profilePicture) + : undefined + const coverArtFile = row.coverArt + ? await resolveArtistProfileAssetFile(row, row.coverArt) + : undefined + + if (!Object.keys(metadata).length && !profilePictureFile && !coverArtFile) { + throw new Error(`artist profile update has no supported fields`) + } + + const sdk = getSdk(source) + const result = await sdk.users.updateUser({ + id: row.audiusUser, + userId: row.audiusUser, + metadata, + ...(profilePictureFile + ? { profilePictureFile: profilePictureFile as any } + : {}), + ...(coverArtFile ? { coverArtFile: coverArtFile as any } : {}), + } as any) + + if ('key' in row) { + await artistProfileUpdateRepo.update({ + key: row.key, + status: ArtistProfileUpdateStatus.Published, + publishedAt: new Date().toISOString(), + ...sdkWriteResultFields(result), + }) + + if (row.displayName) { + const user = await userRepo.findByIdAndApiKey( + row.audiusUser, + source.ddexKey + ) + if (user) { + await userRepo.upsert({ + ...user, + name: row.displayName, + }) + } + } + } + + return result +} + export async function publishRelease( source: SourceConfig, releaseRow: ReleaseRow, @@ -774,3 +867,41 @@ async function resolveReleaseAssetFile( ...(detected?.mime ? { type: detected.mime } : {}), } } + +async function resolveArtistProfileAssetFile( + row: Pick | DDEXArtistProfileUpdate, + resource: DDEXResource +): Promise { + if (resource.uri && /^https?:\/\//i.test(resource.uri)) { + const response = await fetch(resource.uri) + if (!response.ok) { + throw new Error( + `failed to fetch MEAD image ${resource.uri}: ${response.status}` + ) + } + const buffer = Buffer.from(await response.arrayBuffer()) + const detected = await fileTypeFromBuffer(buffer) + return { + buffer, + name: resource.fileName || resource.uri, + type: response.headers.get('content-type') || detected?.mime || undefined, + } + } + + if (!('xmlUrl' in row) || !row.xmlUrl) { + throw new Error(`xmlUrl is required to resolve MEAD artist profile image`) + } + + const assetData = await readAssetWithCaching( + row.xmlUrl, + resource.filePath || '', + resource.fileName + ) + const buffer = normalizeAssetBuffer(assetData) + const detected = await fileTypeFromBuffer(buffer) + return { + buffer, + name: resource.fileName, + ...(detected?.mime ? { type: detected.mime } : {}), + } +}