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 } : {}),
+ }
+}