Index: src/services/ap-core.js
===================================================================
--- src/services/ap-core.js	(revision 38d5e400fb37c125a21afb62365799db49afb0a3)
+++ src/services/ap-core.js	(revision 9a585bf85092daf7cdfe5cb7983122f7018082d8)
@@ -88,4 +88,22 @@
     Track: 'fw:Track',
     track: { '@id': 'fw:track', '@type': '@id' },
+    // ARTIEST-CREDIT. Hun TrackSerializer eist minstens een artist_credit, en
+    // dat leek lang onmogelijk: het vraagt een Artist met een eigen id, en bij
+    // ons was een artiest tekst. Sinds de MusicBrainz-koppeling (shaer-mbz) is
+    // dat niet meer waar -- de site-ACTOR is de artiest. Een echte, opvraagbare
+    // URI, met de sitetitel als naam en een musicbrainzId als hij gekoppeld is.
+    // Er valt hier niets te verzinnen; open.audio leidde dit zelfs al zelf af
+    // uit onze attributedTo (gemeten 13-8).
+    //
+    // `@container: @list` is GEEN opsmuk. Ze lezen dit veld met
+    // first_attr(FW.artist_credit, "@list"), en zonder die declaratie expandeert
+    // onze array niet naar een @list -- dan staat er iets dat er goed uitziet en
+    // door hun lezer niet gevonden wordt. Letterlijk hun contexts.py regel 311.
+    Artist: 'fw:Artist',
+    ArtistCredit: 'fw:ArtistCredit',
+    artist: { '@id': 'fw:artist', '@type': '@id' },
+    artist_credit: { '@id': 'fw:artist_credit', '@type': '@id', '@container': '@list' },
+    credit: 'fw:credit',
+    musicbrainzId: 'fw:musicbrainzId',
     position: 'schema:position',
     bitrate: 'schema:bitrate',
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision 38d5e400fb37c125a21afb62365799db49afb0a3)
+++ src/services/music/index.js	(revision 9a585bf85092daf7cdfe5cb7983122f7018082d8)
@@ -14,5 +14,5 @@
 
 import db from '../../config/database.js';
-import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection } from '../ap-core.js';
+import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection, isMbid } from '../ap-core.js';
 import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js';
 // De luisteraars horen bij de muziekkant; hier doorgegeven zodat
@@ -195,9 +195,43 @@
   // een tekstkolom; er hier een adres van maken zou een ding beloven dat niet
   // bestaat. Zie shaer-k37k -- dat is de keuze die daarvoor eerst moet vallen.
+  //
+  // WIE IS DE ARTIEST. Hun Artist is een ENTITEIT met een id, en bij ons is een
+  // artiest een tekstkolom op de track. Die twee verzoenen we zo: de entiteit
+  // is de site-ACTOR -- een echt, opvraagbaar adres, het account dat dit
+  // uitbrengt -- en de tekst uit de kolom gaat naar `credit`, want dat is
+  // precies waar hun model de credittekst verwacht.
+  //
+  // Dat is eerlijk en het is niet nieuw: open.audio leidde op 13-8 al zelf een
+  // artist_credit af uit onze attributedTo. We maken alleen expliciet wat daar
+  // toch al gebeurde.
+  //
+  // DE GRENS ERVAN: brengt een site werk van iemand anders uit, dan zegt dit
+  // dat de site de artiest is. Dat stond al in attributedTo, dus we maken het
+  // niet erger -- maar het is wel de reden dat we hier geen id per artiestnaam
+  // verzinnen. Identiteit uit een string is dezelfde fout als bij het album
+  // (shaer-756s).
+  const wanneer = r.created_at ? new Date(r.created_at).toISOString()
+    : (site.created_at ? new Date(site.created_at).toISOString() : new Date(0).toISOString());
+  const artiest = {
+    type: 'Artist',
+    id: actorId(base, site.slug),
+    name: site.title || site.slug,
+    published: site.created_at ? new Date(site.created_at).toISOString() : wanneer,
+  };
+  if (isMbid(site.mb_artist_id)) artiest.musicbrainzId = String(site.mb_artist_id).trim().toLowerCase();
+
   a.track = {
     type: 'Track',
     id: `${a.id}#track`,
     name: a.name,
+    published: wanneer,
     ...(Number(r.position) ? { position: Number(r.position) } : {}),
+    artist_credit: [{
+      type: 'ArtistCredit',
+      id: `${a.id}#artist-credit`,
+      published: wanneer,
+      artist: artiest,
+      ...(r.artist ? { credit: r.artist } : {}),
+    }],
   };
   if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
Index: test/ap-fw-track.test.js
===================================================================
--- test/ap-fw-track.test.js	(revision 38d5e400fb37c125a21afb62365799db49afb0a3)
+++ test/ap-fw-track.test.js	(revision 9a585bf85092daf7cdfe5cb7983122f7018082d8)
@@ -84,2 +84,54 @@
   for (const a of col.orderedItems) assert.equal(a.track.type, 'Track', `${a.name} zonder track`);
 });
+
+// ── artist_credit (shaer-3f8a, het gat naar hun ingest) ──────────────
+//
+// TrackSerializer (regel 1569) eist `artist_credit` met min_length=1, en elke
+// ArtistCredit eist een Artist die zelf id, name en published nodig heeft
+// (MusicEntitySerializer, regel 1278). Dat leek onmogelijk zolang een artiest
+// bij ons tekst was -- tot de MusicBrainz-koppeling liet zien dat de ACTOR de
+// artiest is.
+
+test('de track draagt een artist_credit met een echte Artist', () => {
+  const a = audio('t1');
+  assert.ok(Array.isArray(a.track.artist_credit), 'geen lijst');
+  assert.equal(a.track.artist_credit.length, 1);
+  const ac = a.track.artist_credit[0];
+  assert.equal(ac.type, 'ArtistCredit');
+  assert.ok(ac.id && ac.published, 'ArtistCredit mist id of published');
+  assert.equal(ac.artist.type, 'Artist');
+  assert.equal(ac.artist.id, `${BASE}/ap/users/band`, 'de actor IS de artiest');
+  assert.equal(ac.artist.name, 'De Band');
+  assert.ok(ac.artist.published, 'Artist mist published');
+});
+
+test('de artiestnaam van de track gaat naar credit, niet naar de entiteit', () => {
+  // De kolom is een credittekst, geen identiteit. Er een id van maken zou
+  // dezelfde fout zijn als bij het album (shaer-756s).
+  assert.equal(audio('t1').track.artist_credit[0].credit, 'De Band');
+  assert.equal(audio('t2').track.artist_credit[0].credit, undefined, 'geen artiest, geen credit');
+});
+
+test('de track heeft published -- MusicEntitySerializer eist het', () => {
+  assert.ok(audio('t1').track.published);
+});
+
+test('artist_credit is een @list in de context, anders vindt hun lezer niets', async () => {
+  // Ze lezen dit met first_attr(FW.artist_credit, "@list"). Zonder de
+  // container-declaratie expandeert onze array niet naar een @list en staat er
+  // iets dat er goed uitziet en niet gevonden wordt.
+  const core = await import('../src/services/ap-core.js');
+  const term = core.AP_CONTEXT.find((x) => x && typeof x === 'object' && x.artist_credit);
+  assert.deepEqual(term.artist_credit, { '@id': 'fw:artist_credit', '@type': '@id', '@container': '@list' });
+  assert.equal(term.ArtistCredit, 'fw:ArtistCredit');
+  assert.equal(term.Artist, 'fw:Artist');
+});
+
+test('musicbrainzId komt mee zodra de site gekoppeld is', () => {
+  assert.equal(audio('t1').track.artist_credit[0].artist.musicbrainzId, undefined);
+  db.prepare("UPDATE sites SET mb_artist_id = '5441c29d-3602-4898-b1a1-b77fa23b8e50' WHERE id = 's1'").run();
+  const s2 = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+  const a = AP.buildTrackAudio(BASE, s2, AP.openTrack('s1', 't1'), { hostPosts: null });
+  assert.equal(a.track.artist_credit[0].artist.musicbrainzId, '5441c29d-3602-4898-b1a1-b77fa23b8e50');
+  db.prepare("UPDATE sites SET mb_artist_id = NULL WHERE id = 's1'").run();
+});
