Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 8455dac40de53d0edba192fe5314007b8ac8dc0c)
+++ src/config/database.js	(revision a1178626b3c552f9fffcedb21e83001e34248067)
@@ -134,4 +134,10 @@
   ensureColumn('ap_follow_reviews', 'target_handle', 'TEXT');
   ensureColumn('sites', 'profile_photo', 'TEXT');
+  // De MusicBrainz-koppeling van de artiest (shaer-mbz). Een MBID is een
+  // verwijzing naar hun register, geen kopie ervan -- de naam staat erbij zodat
+  // het beheerscherm kan tonen WAT er gekoppeld is zonder ervoor te moeten
+  // netwerken, en zodat een verkeerde koppeling opvalt.
+  ensureColumn('sites', 'mb_artist_id', 'TEXT');
+  ensureColumn('sites', 'mb_artist_name', 'TEXT');
   ensureColumn('audio_tracks', 'cover_url', 'TEXT');
   ensureColumn('audio_tracks', 'album', 'TEXT');
Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision 8455dac40de53d0edba192fe5314007b8ac8dc0c)
+++ src/routes/admin-audio.js	(revision a1178626b3c552f9fffcedb21e83001e34248067)
@@ -23,4 +23,5 @@
 import { audioUrl } from '../services/AudioStreamService.js';
 import { mediaDir } from '../config/paths.js';
+import MusicBrainz from '../services/MusicBrainzService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -383,4 +384,34 @@
 
 /** GET /admin/audio/api/albums — distinct list of album names (for datalist) */
+/**
+ * "Ben jij dit?" -- kandidaten uit MusicBrainz (shaer-mbz, stap 1).
+ *
+ * De zoekopdracht draait HIER en niet in de browser: MusicBrainz staat een
+ * verzoek per seconde toe per APPLICATIE, en dat is alleen af te dwingen als
+ * alles langs een plek gaat. Bovendien eisen ze een User-Agent met contact, en
+ * die kan een browser niet zetten.
+ *
+ * De keuze blijft van de artiest. Wij tonen kandidaten met hun toelichting; we
+ * kiezen er niet zelf een, ook niet als er maar een treffer is -- een verkeerd
+ * geraden MBID koppelt iemand aan het werk van een ander.
+ */
+router.get('/api/musicbrainz', requireGod, async (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  // Standaard de artiestennaam die al in de site staat: negen van de tien keer
+  // is dat precies waar iemand op zou zoeken.
+  const q = String(req.query.q || site.author || site.title || '').trim();
+  if (!q) return res.json({ ok: true, q: '', kandidaten: [] });
+  const kandidaten = await MusicBrainz.zoekArtiesten(q);
+  res.json({
+    ok: true,
+    q,
+    gekoppeld: site.mb_artist_id
+      ? { mbid: site.mb_artist_id, naam: site.mb_artist_name || '', url: MusicBrainz.artiestUrl(site.mb_artist_id) }
+      : null,
+    kandidaten,
+  });
+});
+
 router.get('/api/albums', requireGod, (req, res) => {
   const site = res.locals.site;
Index: src/services/MusicBrainzService.js
===================================================================
--- src/services/MusicBrainzService.js	(revision a1178626b3c552f9fffcedb21e83001e34248067)
+++ src/services/MusicBrainzService.js	(revision a1178626b3c552f9fffcedb21e83001e34248067)
@@ -0,0 +1,109 @@
+/**
+ * Een artiest zoekt zichzelf op in MusicBrainz (shaer-mbz).
+ *
+ * WAAROM DIT GEEN DIALECT IS. Funkwhale's Track/Artist/ArtistCredit zijn hun
+ * eigen vocabulaire -- hun docs noemen ze letterlijk "Custom Funkwhale object"
+ * -- en wij kunnen ze niet eerlijk vullen: artiest en album zijn bij ons
+ * tekstkolommen, geen entiteiten. Een MBID is iets anders: geen vocabulaire
+ * maar een REGISTER. Ernaar verwijzen is als een ISBN noemen. Je neemt niemands
+ * model over en je wijst naar iets dat al bestaat.
+ *
+ * WAT HIER NIET GEBEURT: schrijven. Via hun API zijn alleen tags, ratings,
+ * ISRC's en barcodes in te dienen -- artiesten, releases en recordings niet,
+ * dat gaat via hun website. Wij lezen dus alleen, en dat is meteen de
+ * geruststelling: we kunnen hun register niet vervuilen.
+ *
+ * TWEE HARDE REGELS VAN HUN KANT, allebei hieronder ingebakken omdat ze bij
+ * overtreding tot blokkade leiden en niet tot een foutmelding:
+ *   - hoogstens EEN verzoek per seconde, per applicatie (niet per bezoeker)
+ *   - een echte User-Agent, met contactgegevens
+ */
+import { safeFetch } from './ActivityPubService.js';
+
+const BASIS = 'https://musicbrainz.org/ws/2';
+
+/**
+ * De User-Agent die MusicBrainz eist. Hun regel: naam, versie en een manier om
+ * contact op te nemen. Een lege of generieke string is precies waarop ze
+ * blokkeren, dus als er geen contact is ingesteld zeggen we dat met zoveel
+ * woorden in plaats van iets aardigs te verzinnen.
+ */
+function userAgent() {
+  const contact = (process.env.MUSICBRAINZ_CONTACT || '').trim()
+    || (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '')
+    || 'geen-contact-ingesteld';
+  return `Klonkt/1.0 ( ${contact} )`;
+}
+
+/**
+ * Hun tempo aanhouden: ten hoogste een verzoek per seconde, over de HELE
+ * applicatie. Geen bibliotheek en geen wachtrij -- een belofte die de volgende
+ * aanroeper laat wachten tot het weer mag. Zonder dit is de eerste drukke dag
+ * meteen een blokkade, en dan werkt het bij iedereen niet meer.
+ */
+let laatste = 0;
+let beurt = Promise.resolve();
+function opDeBeurt() {
+  beurt = beurt.then(async () => {
+    const wachten = 1000 - (Date.now() - laatste);
+    if (wachten > 0) await new Promise((r) => setTimeout(r, wachten));
+    laatste = Date.now();
+  });
+  return beurt;
+}
+
+/**
+ * Zoek artiesten op naam. Geeft de kandidaten met alles wat nodig is om er EEN
+ * uit te kiezen -- de naam alleen is niet genoeg, want er zijn drie bands die
+ * Nirvana heten. Vandaar disambiguation, land en de jaren erbij.
+ *
+ * Geeft een LEGE lijst bij een storing, geen exceptie: niet kunnen zoeken is
+ * vervelend, maar het mag het beheerscherm niet omvergooien.
+ */
+export async function zoekArtiesten(naam, { limit = 8 } = {}) {
+  const q = String(naam || '').trim();
+  if (!q) return [];
+  const url = `${BASIS}/artist?query=${encodeURIComponent(q)}&fmt=json&limit=${Math.min(25, Math.max(1, limit))}`;
+  try {
+    await opDeBeurt();
+    const r = await safeFetch(url, { headers: { Accept: 'application/json', 'User-Agent': userAgent() } });
+    if (!r || !r.ok) return [];
+    const doc = await r.json();
+    return (doc.artists || []).map(kandidaat).filter(Boolean);
+  } catch {
+    return [];
+  }
+}
+
+/** Een kandidaat, teruggebracht tot wat een mens nodig heeft om te kiezen. */
+function kandidaat(a) {
+  if (!a || !a.id || !a.name) return null;
+  const jaren = [a['life-span']?.begin, a['life-span']?.ended ? a['life-span']?.end : null]
+    .filter(Boolean).join(' – ');
+  return {
+    mbid: a.id,
+    naam: a.name,
+    // "disambiguation" is het veld waarmee MusicBrainz zelf twee gelijknamige
+    // artiesten uit elkaar houdt. Precies wat de kiezer nodig heeft.
+    toelichting: a.disambiguation || '',
+    soort: a.type || '',            // Person, Group, ...
+    land: a.country || '',
+    jaren,
+    url: `https://musicbrainz.org/artist/${a.id}`,
+    // Hun eigen zoekscore. Niet om op te sorteren -- dat doen zij al -- maar om
+    // een zwakke treffer te kunnen tonen als zwak.
+    score: Number(a.score) || 0,
+  };
+}
+
+/** Is dit een MBID? Een UUID, en niets anders. */
+export function isMbid(s) {
+  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(s || '').trim());
+}
+
+/** De publieke pagina van een artiest, of null als het geen MBID is. */
+export function artiestUrl(mbid) {
+  return isMbid(mbid) ? `https://musicbrainz.org/artist/${String(mbid).trim().toLowerCase()}` : null;
+}
+
+export default { zoekArtiesten, isMbid, artiestUrl };
Index: test/musicbrainz.test.js
===================================================================
--- test/musicbrainz.test.js	(revision a1178626b3c552f9fffcedb21e83001e34248067)
+++ test/musicbrainz.test.js	(revision a1178626b3c552f9fffcedb21e83001e34248067)
@@ -0,0 +1,98 @@
+// De MusicBrainz-koppeling, stap 1 (shaer-mbz).
+//
+// Zonder netwerk: globalThis.fetch wordt vervangen en musicbrainz.org staat in
+// AP_ALLOW_HOSTS, zodat safeFetch geen DNS doet. Anders test dit of de
+// testmachine internet heeft en of MusicBrainz toevallig up is.
+//
+// Wat hier WEL getest wordt is wat wij beloven: hun twee harde regels. Die
+// leiden bij overtreding tot een BLOKKADE en niet tot een foutmelding, dus
+// "het werkte toen ik het probeerde" is er geen bewijs voor.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://ons.test';
+process.env.MUSICBRAINZ_CONTACT = 'robin@ons.test';
+process.env.AP_ALLOW_HOSTS = 'musicbrainz.org';
+
+const dbMod = await import('../src/config/database.js');
+dbMod.initializeDatabase();
+const MB = await import('../src/services/MusicBrainzService.js');
+
+const verzoeken = [];
+const echt = globalThis.fetch;
+globalThis.fetch = async (url, opts) => {
+  verzoeken.push({ url: String(url), ua: opts?.headers?.['User-Agent'], op: Date.now() });
+  return {
+    ok: true,
+    status: 200,
+    headers: new Map(),
+    json: async () => ({
+      artists: [
+        { id: '8be31978-1884-4773-beae-f73df35b92aa', name: 'Nirvana', score: 100,
+          disambiguation: 'Seattle grunge band', type: 'Group', country: 'US',
+          'life-span': { begin: '1987', ended: true, end: '1994' } },
+        { id: 'aaaaaaaa-1111-2222-3333-444444444444', name: 'Nirvana', score: 72,
+          disambiguation: '60s UK band', type: 'Group', country: 'GB' },
+        { id: 'bad', name: '' },   // onbruikbaar: moet eruit vallen
+      ],
+    }),
+  };
+};
+
+test('een MBID is een UUID en niets anders', () => {
+  assert.equal(MB.isMbid('8be31978-1884-4773-beae-f73df35b92aa'), true);
+  assert.equal(MB.isMbid('the_ceeesg'), false, 'een handle is geen MBID');
+  assert.equal(MB.isMbid('https://musicbrainz.org/artist/8be31978-1884-4773-beae-f73df35b92aa'), false,
+    'een URL ook niet -- anders sluipt er een hele link de kolom in');
+  assert.equal(MB.isMbid(''), false);
+  assert.equal(MB.isMbid(null), false);
+});
+
+test('de artiest-URL wordt alleen uit een echte MBID gebouwd', () => {
+  assert.equal(MB.artiestUrl('8BE31978-1884-4773-BEAE-F73DF35B92AA'),
+    'https://musicbrainz.org/artist/8be31978-1884-4773-beae-f73df35b92aa',
+    'kleingeschreven, want een MBID is er een en niet twee');
+  assert.equal(MB.artiestUrl('rommel'), null, 'geen URL verzinnen om iets onbekends heen');
+});
+
+test('een lege zoekopdracht raakt het net niet eens', async () => {
+  verzoeken.length = 0;
+  assert.deepEqual(await MB.zoekArtiesten('   '), []);
+  assert.equal(verzoeken.length, 0, 'hun tempo is te kostbaar om aan niets te besteden');
+});
+
+test('de User-Agent noemt Klonkt EN een contact -- hun eis', async () => {
+  // Een generieke of lege User-Agent is precies waarop MusicBrainz blokkeert.
+  verzoeken.length = 0;
+  await MB.zoekArtiesten('iemand');
+  const ua = verzoeken.at(-1)?.ua;
+  assert.ok(ua, 'er is er een gezet');
+  assert.match(ua, /^Klonkt\//, 'met onze naam voorop');
+  assert.match(ua, /robin@ons\.test/, 'en een manier om contact op te nemen');
+});
+
+test('de kandidaten dragen wat een mens nodig heeft om te KIEZEN', async () => {
+  const uit = await MB.zoekArtiesten('Nirvana');
+  assert.equal(uit.length, 2, 'een naamloze treffer is geen kandidaat');
+  assert.equal(uit[0].mbid, '8be31978-1884-4773-beae-f73df35b92aa');
+  // De naam alleen is niet genoeg: er zijn drie bands die Nirvana heten.
+  assert.equal(uit[0].toelichting, 'Seattle grunge band');
+  assert.equal(uit[1].toelichting, '60s UK band');
+  assert.equal(uit[0].jaren, '1987 – 1994');
+  assert.equal(uit[1].jaren, '', 'een band die nog bestaat krijgt geen eindjaar aangepraat');
+  assert.equal(uit[0].url, 'https://musicbrainz.org/artist/8be31978-1884-4773-beae-f73df35b92aa');
+});
+
+test('twee zoekopdrachten liggen minstens een seconde uit elkaar', async () => {
+  // Hun harde regel, en de reden dat dit server-side draait: een verzoek per
+  // seconde geldt per APPLICATIE, niet per bezoeker. Twee tabbladen tegelijk
+  // zouden hem anders samen overtreden.
+  verzoeken.length = 0;
+  await Promise.all([MB.zoekArtiesten('een'), MB.zoekArtiesten('twee')]);
+  assert.equal(verzoeken.length, 2);
+  const gat = verzoeken[1].op - verzoeken[0].op;
+  assert.ok(gat >= 990, `verwacht >= 1000ms tussen twee verzoeken, was ${gat}ms`);
+});
+
+test.after(() => { globalThis.fetch = echt; });
