Index: src/services/MigrationService.js
===================================================================
--- src/services/MigrationService.js	(revision f894be507abfa49e63a08d5ea515aa39faade79f)
+++ src/services/MigrationService.js	(revision 9f9b45f0192cafb95f5a26092edd4a5e62d1cb6b)
@@ -239,4 +239,29 @@
 
 const AFBEELDING = /^image\//i;
+
+/**
+ * Waar kan de omslag van een bericht zitten?
+ *
+ * Niet alleen in `attachment`. Klonkt onderdrukt de beeldbijlage met opzet
+ * zodra een post een speler of embed heeft (zie noImages in buildNote), en zet
+ * de cover dan in `image` zodat Mastodon zijn spelerkaart toont en een Klonkt
+ * hem alsnog vindt. Dat is precies wat er bij Robin misging: 18 van de 20
+ * berichten op pagina 1 hadden geen enkele bijlage, en toch een cover, en die
+ * viel er stil tussenuit.
+ *
+ * En een Audio-bijlage draagt zijn eigen hoes in `icon`; die telt ook mee.
+ */
+function coverKandidaten(o) {
+  const uit = [];
+  const pak = (v) => {
+    if (!v) return;
+    const u = typeof v === 'string' ? v : (v.url && (typeof v.url === 'string' ? v.url : v.url.href)) || v.href;
+    if (u && /^https?:\/\//i.test(String(u))) uit.push(String(u));
+  };
+  pak(o.image);
+  pak(o.icon);
+  for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) pak(a && a.icon);
+  return [...new Set(uit)];
+}
 
 /**
@@ -521,15 +546,20 @@
           }
           if (!bestaand.cover_image_url && safeFetch && fs && path && mediaRoot) {
-            // De cover alsnog: hij zit als bijlage op de Note.
-            for (const a of (Array.isArray(o.attachment) ? o.attachment : []).slice(0, 20)) {
-              const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
-              if (!u || !AFBEELDING.test(String((a && a.mediaType) || ''))) continue;
+            // De omslag alsnog. Uit de beeldbijlage als die er is, anders uit
+            // image/icon: bij een post met een speler staat hij daar.
+            const uit = [
+              ...(Array.isArray(o.attachment) ? o.attachment : [])
+                .filter((a) => AFBEELDING.test(String((a && a.mediaType) || '')))
+                .map((a) => (typeof a.url === 'string' ? a.url : (a.url && a.url.href)))
+                .filter(Boolean),
+              ...coverKandidaten(o),
+            ];
+            for (const u of uit) {
               const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
               const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
-              if (g) {
-                db.prepare('UPDATE posts SET cover_image_url = ? WHERE id = ?').run(g.url, bestaand.id);
-                rapport.media++;
-                rapport.postsBijgewerkt++;
-              }
+              if (!g) continue;
+              db.prepare('UPDATE posts SET cover_image_url = ? WHERE id = ?').run(g.url, bestaand.id);
+              rapport.media++;
+              rapport.postsBijgewerkt++;
               break;
             }
@@ -569,4 +599,19 @@
           const r2 = await inhoudMediaBinnen(inhoud, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
           inhoud = r2.inhoud;
+          // De omslag zit lang niet altijd in attachment (zie coverKandidaten).
+          if (!binnen.some((b) => AFBEELDING.test(b.type || ''))) {
+            for (const u of coverKandidaten(o)) {
+              const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
+              const g = await haalBijlage(u, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
+              if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`omslag niet opgehaald: ${u}`); continue; }
+              binnen.unshift({ ...g, naam: null, type: g.mediaType });
+              rapport.media++;
+              try {
+                db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
+                  .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
+              } catch { /* administratie */ }
+              break;                       // een omslag is genoeg
+            }
+          }
         }
 
Index: test/fep1580-migration.test.js
===================================================================
--- test/fep1580-migration.test.js	(revision f894be507abfa49e63a08d5ea515aa39faade79f)
+++ test/fep1580-migration.test.js	(revision 9f9b45f0192cafb95f5a26092edd4a5e62d1cb6b)
@@ -536,4 +536,45 @@
 });
 
+test('de omslag komt ook mee als hij in `image` zit in plaats van in attachment', async () => {
+  // Robins 9 covers van de 36. Klonkt onderdrukt de beeldbijlage met opzet
+  // zodra een post een speler of embed heeft (noImages in buildNote), en zet
+  // de omslag dan in `image` zodat Mastodon zijn spelerkaart toont. 18 van de
+  // 20 berichten op pagina 1 hadden daardoor geen enkele bijlage, en toch een
+  // cover. Mijn ingest keek alleen naar attachment.
+  const s = site({ aliases: [BRON] });
+  const OMSLAG = 'https://oud.example/media/post-images/omslag.png';
+  const HOES = 'https://oud.example/media/audio-covers/hoes.webp';
+  const kaart = new Map([
+    [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox` }],
+    [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 2, first: `${BRON}/outbox?page=1` }],
+    [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [
+      // een speler-post: GEEN attachment, omslag in image
+      note(`${BRON}/notes/speler`, { content: '<p>muziek</p>', image: { type: 'Image', url: OMSLAG } }),
+      // een audio-bijlage draagt zijn eigen hoes in icon
+      note(`${BRON}/notes/audio`, { content: '<p>track</p>', attachment: [
+        { type: 'Audio', mediaType: 'audio/mpeg', url: 'https://oud.example/audio/stream/x.mp3',
+          icon: { type: 'Image', mediaType: 'image/webp', url: HOES } },
+      ] }),
+    ] }],
+  ]);
+  const opgehaald = [];
+  const r = await stil(() => Mig.ingestFromSource(s, { deps: {
+    getJson: async (_s, url) => kaart.get(url) || null,
+    noteId: (b, id) => `${b}/ap/notes/${id}`,
+    noteVisibility: AP.noteVisibility,
+    mediaRoot: '/nep/media', audioRoot: '/nep/audio',
+    signHeaders: () => ({ Signature: 'nep' }),
+    safeFetch: async (url) => { opgehaald.push(url); return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'image/png' } }; },
+    fs: { mkdirSync() {}, writeFileSync() {}, statSync() { throw new Error('ENOENT'); } },
+    path,
+  } }));
+  assert.equal(r.posts, 2);
+  assert.ok(opgehaald.includes(OMSLAG), 'de omslag uit `image` hoort opgehaald te worden');
+  assert.ok(opgehaald.includes(HOES), 'en de hoes uit de icon van een Audio-bijlage ook');
+  const covers = db.prepare('SELECT cover_image_url c FROM posts WHERE site_id = ?').all('s1').map((x) => x.c);
+  assert.equal(covers.filter(Boolean).length, 2, 'beide berichten krijgen hun omslag');
+  assert.ok(covers.includes('/media/post-images/omslag.png'), 'op het pad van de bron');
+});
+
 test('een bericht dat je zelf hebt verwijderd komt bij een tweede ronde terug', async () => {
   // Robin: "ik kan handmatig deze keer de posts verwijderen en opnieuw ophalen."
