Index: src/services/MigrationService.js
===================================================================
--- src/services/MigrationService.js	(revision eb071a7d1d9176c55366b8760511f2926c3de134)
+++ src/services/MigrationService.js	(revision 919b82d343b38d9d40af336bd94eda6a74068b2b)
@@ -226,4 +226,9 @@
   for (let n = 2; n < 500; n++) if (!bestaat.get(siteId, `${schoon}-${n}`)) return `${schoon}-${n}`;
   return `${schoon}-${crypto.randomBytes(4).toString('hex')}`;
+}
+
+/** Het kale id uit een track-URI: .../tracks/t-een -> t-een. */
+function ruwId(uri) {
+  try { return decodeURIComponent(String(uri).split('/').filter(Boolean).pop() || ''); } catch { return ''; }
 }
 
@@ -361,5 +366,5 @@
 
   const rapport = {
-    bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
+    bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, media: 0, mediaMislukt: 0,
     blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, overgeslagenTracks: 0,
     playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
@@ -412,5 +417,17 @@
         if (auteur && auteur !== bronActor.id) continue;              // alleen wat van HEM was
         gezien++;
-        if (alGemigreerd(site.slug, o.id)) { rapport.overgeslagen++; continue; }
+        // Al binnen? Alleen overslaan als het bericht er OOK nog staat. Heb je
+        // het verwijderd, dan is opnieuw ophalen precies wat je bedoelt, en
+        // een mapping die dat blokkeert is een val: opruimen hielp dan niet,
+        // want de blokkade zat in ap_migration en niet in de posts.
+        const eerderPost = migrationTarget(site.slug, o.id);
+        if (eerderPost) {
+          const postId = String(eerderPost).split('/').pop();
+          if (db.prepare('SELECT 1 FROM posts WHERE id = ? AND site_id = ?').get(decodeURIComponent(postId), site.id)) {
+            rapport.overgeslagen++;
+            continue;
+          }
+          rapport.opnieuw++;   // weg hier, dus opnieuw binnenhalen
+        }
 
         // Media eerst, want een post die naar een plaatje wijst dat we niet
@@ -487,4 +504,9 @@
     // blijven.
     const trackKaart = new Map();   // bron-URI van een nummer -> ons nieuwe id
+    // En het RUWE id zoals het in de posttekst staat. Klonkt schrijft
+    // [[track:<id>]] in de content, en die tekst reist letterlijk mee over AP.
+    // Krijgt het nummer hier een ander id, dan wijst die shorthand nergens meer
+    // heen en zie je de code zelf in je bericht staan.
+    const ruwKaart = new Map();     // ruw bron-id -> ons id
     const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
     const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
@@ -511,4 +533,5 @@
           if (rij) {
             trackKaart.set(String(a.id), rij.id);   // MOET, anders vinden de playlists hem niet
+            ruwKaart.set(ruwId(a.id), rij.id);
             const duur = rij.duration ? null : duurSeconden(a.duration);
             const artiest = rij.artist ? null : (a.summary || a.artist || null);
@@ -571,4 +594,5 @@
           recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
           trackKaart.set(String(a.id), trackId);
+          ruwKaart.set(ruwId(a.id), trackId);
           rapport.tracksBinnen++;
         } catch (e) {
@@ -579,4 +603,26 @@
     } else if (tracksUrl) {
       rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
+    }
+
+    // ── De verwijzingen in de tekst bijtrekken ────────────────────
+    //
+    // Klonkt schrijft [[track:<id>]] in posts.content, en die tekst reist
+    // letterlijk mee. Krijgt het nummer hier een ander id, dan wijst de
+    // shorthand nergens heen en zie je de code zelf in je bericht staan in
+    // plaats van een speler. Precies wat Robin op TikTik zag.
+    //
+    // Pas NA de tracks, want daarvoor is de kaart nog leeg. En alleen waar het
+    // id echt veranderde: een gelijk id hoeft niet aangeraakt.
+    {
+      const paren = [...ruwKaart.entries()].filter(([oud, nieuwId]) => oud && oud !== nieuwId);
+      if (paren.length) {
+        const upd = db.prepare('UPDATE posts SET content = REPLACE(content, ?, ?) WHERE site_id = ? AND content LIKE ?');
+        let n = 0;
+        for (const [oud, nieuwId] of paren) {
+          const r = upd.run(`[[track:${oud}]]`, `[[track:${nieuwId}]]`, site.id, `%[[track:${oud}]]%`);
+          if (r && r.changes) n += r.changes;
+        }
+        if (n) { rapport.tekstBijgewerkt = n; console.log('[FEP-1580] track-verwijzingen bijgetrokken in', n, 'bericht(en)'); }
+      }
     }
 
@@ -608,4 +654,15 @@
         // elke tweede ronde een dubbele plaat op.
         const eerderPl = migrationTarget(site.slug, uri);
+        // De hoes van de plaat, net als bij een nummer.
+        let plHoes = null;
+        const plHoesUrl = (plc.icon && (plc.icon.url || plc.icon)) || (plc.image && (plc.image.url || plc.image)) || null;
+        if (plHoesUrl && /^https?:\/\//i.test(String(plHoesUrl)) && safeFetch && fs && path && mediaRoot) {
+          const h = await haalBijlage(String(plHoesUrl), {
+            safeFetch, mediaRoot, fs, path, maxBytes,
+            headers: signHeaders ? signHeaders(site.slug, String(plHoesUrl), '*/*') : null,
+          }).catch(() => null);
+          if (h) { plHoes = h.url; rapport.media++; }
+          else rapport.waarschuwingen.push(`hoes van playlist niet opgehaald: ${plc.name || uri}`);
+        }
         const plId = (() => {
           if (!eerderPl) return crypto.randomUUID();
@@ -615,8 +672,11 @@
         })();
         try {
-          db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, kind) VALUES (?,?,?,?,?,?)
-                      ON CONFLICT(id) DO UPDATE SET title = excluded.title, artist = COALESCE(playlists.artist, excluded.artist)`)
+          db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, kind, cover_url) VALUES (?,?,?,?,?,?,?)
+                      ON CONFLICT(id) DO UPDATE SET
+                        title = excluded.title,
+                        artist = COALESCE(playlists.artist, excluded.artist),
+                        cover_url = COALESCE(playlists.cover_url, excluded.cover_url)`)
             .run(plId, site.id, plc.name || 'zonder titel', plc.artist || null,
-              plc.year || null, plc['shaer:kind'] || null);
+              plc.year || null, plc['shaer:kind'] || null, plHoes);
           // De volgorde opnieuw zetten: die IS de plaat, en een halve
           // bijgewerkte volgorde is erger dan een verse.
Index: test/fep1580-migration.test.js
===================================================================
--- test/fep1580-migration.test.js	(revision eb071a7d1d9176c55366b8760511f2926c3de134)
+++ test/fep1580-migration.test.js	(revision 919b82d343b38d9d40af336bd94eda6a74068b2b)
@@ -458,4 +458,69 @@
 });
 
+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."
+  // Met de eerste opzet kon dat niet: de mapping in ap_migration zei "al gehad"
+  // en dan werd alles overgeslagen, hoe leeg je site ook was. Dezelfde val als
+  // bij de nummers, en juist deze zou hij als eerste tegenkomen.
+  const s = site({ aliases: [BRON] });
+  const deps = bronnetje({ items: [note(`${BRON}/notes/1`), note(`${BRON}/notes/2`)] });
+
+  const r1 = await stil(() => Mig.ingestFromSource(s, { deps }));
+  assert.equal(r1.posts, 2);
+
+  // Eentje weg, de mapping blijft staan.
+  const weg = db.prepare('SELECT id FROM posts LIMIT 1').get().id;
+  db.prepare('DELETE FROM posts WHERE id = ?').run(weg);
+  assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 1);
+
+  const r2 = await stil(() => Mig.ingestFromSource(s, { deps }));
+  assert.equal(r2.posts, 1, 'het verwijderde bericht hoort terug te komen');
+  assert.equal(r2.opnieuw, 1, 'en het verslag zegt dat het opnieuw is opgehaald');
+  assert.equal(r2.overgeslagen, 1, 'terwijl het bericht dat er nog stond met rust blijft');
+  assert.equal(db.prepare('SELECT COUNT(*) n FROM posts').get().n, 2, 'geen dubbele');
+});
+
+test('de [[track:]]-verwijzing in een bericht wordt bijgetrokken', async () => {
+  // Wat Robin op TikTik zag: het bericht toonde de shorthand zelf in plaats van
+  // een speler. Zijn posts kwamen uit de ZIP (die bewaart posts.content
+  // letterlijk, inclusief [[track:<oud id>]]) en zijn nummers uit de PULL (die
+  // gaf ze een nieuw id). De tekst wees dus naar een nummer dat hier niet
+  // bestaat, en dan valt hij terug op de kale code.
+  const s = site({ aliases: [BRON] });
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
+              VALUES ('pz','s1','u1','tiktik','TikTik','<p>[[track:t-oud]]</p>','published','2026-08-13T10:00:00Z')`).run();
+
+  const kaart = new Map([
+    [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
+    [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
+    [`${BRON}/tracks`, {
+      type: 'OrderedCollection',
+      orderedItems: [{
+        id: `${BRON}/tracks/t-oud`, type: 'Audio', name: 'Nummer',
+        url: [{ type: 'Link', href: `${BRON}/audio/x.mp3`, mediaType: 'audio/mpeg' }],
+      }],
+    }],
+  ]);
+  await stil(() => Mig.ingestFromSource(s, {
+    deps: {
+      getJson: async (_slug, url) => kaart.get(url) || null,
+      noteId: (b, id) => `${b}/ap/notes/${id}`,
+      noteVisibility: AP.noteVisibility,
+      audioRoot: '/nep/audio', mediaRoot: '/nep/media',
+      signHeaders: () => ({ Signature: 'nep' }),
+      safeFetch: async () => ({ ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } }),
+      fs: { mkdirSync() {}, writeFileSync() {} },
+      path,
+    },
+  }));
+
+  const inhoud = db.prepare("SELECT content FROM posts WHERE id = 'pz'").get().content;
+  const m = /\[\[track:([^\]]+)\]\]/.exec(inhoud);
+  assert.ok(m, 'de shorthand blijft staan, alleen het id verandert');
+  assert.notEqual(m[1], 't-oud', 'het oude id wijst hier nergens heen');
+  const bestaat = db.prepare('SELECT 1 FROM audio_tracks WHERE id = ? AND site_id = ?').get(m[1], 's1');
+  assert.ok(bestaat, 'en het nieuwe id hoort bij een nummer dat er echt is');
+});
+
 test('een niet-publiek bericht komt wel mee maar niet in de publieke tabel', async () => {
   const s = site({ aliases: [BRON] });
