Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 9529d7bda5061cdf8e8bbe1bd9d5cdfeae625f66)
+++ src/routes/activitypub.js	(revision 98acb9794ff741fff4649f5e6c62281e3dc3ae80)
@@ -1165,5 +1165,5 @@
 });
 
-router.get('/ap/users/:slug/playlists/:id', (req, res) => {
+router.get('/ap/users/:slug/playlists/:id', async (req, res) => {
   const site = publicSite(req.params.slug);
   if (!site) return res.status(404).end();
@@ -1171,5 +1171,8 @@
     .get(req.params.id, site.id);
   if (!pl) return res.status(404).end();
-  AP.sendAP(res, AP.buildPlaylistCollection(baseUrl(req), site, pl, AP.playlistOpenTracks(pl.id)));
+  // De doel-actor van een verhuizing krijgt de VOLLEDIGE plaat, niet alleen de
+  // nummers die voor de fediverse opengezet zijn (FEP-1580).
+  const alles = await magAlles(req, site.slug);
+  AP.sendAP(res, AP.buildPlaylistCollection(baseUrl(req), site, pl, AP.playlistOpenTracks(pl.id, { alles })));
 });
 
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 9529d7bda5061cdf8e8bbe1bd9d5cdfeae625f66)
+++ src/services/ArchiveExportService.js	(revision 98acb9794ff741fff4649f5e6c62281e3dc3ae80)
@@ -182,5 +182,25 @@
  * @returns {Map<string,string>} trackId -> pad in het archief
  */
-function audioBibliotheek(site, bestanden, tellingen, ontbrekend) {
+/**
+ * Een hoes in het archief leggen.
+ *
+ * cover_url reisde wel mee als STRING en het bestand niet, dus kwam een track
+ * aan met een verwijzing naar een plaatje dat er niet was. Precies dezelfde
+ * fout als bij de audio zelf, een laag hoger: een verwijzing zonder bytes.
+ *
+ * @returns {string|null} het pad in het archief, of null
+ */
+function hoesToevoegen(url, origin, bestanden, tellingen) {
+  const schijf = localMediaPath(url, origin);
+  if (!schijf) return null;
+  let bytes = null;
+  try { bytes = fs.readFileSync(schijf); } catch { return null; }
+  const hash = sha256(bytes);
+  const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
+  if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
+  return naam;
+}
+
+function audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend) {
   const kaart = new Map();
   let tracks = [];
@@ -217,4 +237,6 @@
       credit: t.credit || undefined, license: t.license || undefined,
       'shaer:coverUrl': t.cover_url || undefined,
+      // De BYTES van de hoes, niet alleen de verwijzing.
+      'shaer:coverFile': hoesToevoegen(t.cover_url, origin, bestanden, tellingen) || undefined,
       'shaer:downloadable': t.downloadable ? 1 : 0,
       'shaer:fediOpen': t.fedi_open ? 1 : 0,
@@ -243,4 +265,5 @@
         id: p.id, name: p.title || '', artist: p.artist || undefined, year: p.year || undefined,
         'shaer:kind': p.kind || undefined, 'shaer:coverUrl': p.cover_url || undefined,
+        'shaer:coverFile': hoesToevoegen(p.cover_url, origin, bestanden, tellingen) || undefined,
         'shaer:tracks': db.prepare('SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY position')
           .all(p.id).map((r) => ({ id: r.track_id, position: r.position })),
@@ -459,5 +482,5 @@
   // De audiobibliotheek EERST. De posts verwijzen ernaar met [[track:]], dus de
   // kaart moet klaar zijn voor de eerste post gebouwd wordt.
-  const audioKaart = audioBibliotheek(site, bestanden, tellingen, ontbrekend);
+  const audioKaart = audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend);
 
   // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision 9529d7bda5061cdf8e8bbe1bd9d5cdfeae625f66)
+++ src/services/ArchiveImportService.js	(revision 98acb9794ff741fff4649f5e6c62281e3dc3ae80)
@@ -135,4 +135,22 @@
  * (en bij een droogloop dus niet, maar het verslag klopt wel)
  */
+/**
+ * Een hoes uit het archief terugzetten. Geeft het nieuwe /media-pad terug, of
+ * null als het bestand er niet in zat: dan liever GEEN cover_url dan een
+ * verwijzing naar niets.
+ */
+function hoesTerug(files, bestand, werk) {
+  if (!bestand) return null;
+  const bytes = files.get(bestand);
+  if (!bytes || !bytes.length) return null;
+  const naam = path.basename(String(bestand));
+  if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) return null;
+  const urlPad = `/media/archief/${naam}`;
+  const doel = veiligMediaPad(urlPad);
+  if (!doel) return null;
+  werk.push({ soort: 'media', doel, bytes });
+  return urlPad;
+}
+
 function tracksTerug(files, site, rapport) {
   const buf = files.get('tracks.json');
@@ -160,5 +178,6 @@
       continue;
     }
-    werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam });
+    const hoes = hoesTerug(files, t['shaer:coverFile'], werk);
+    werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam, hoes });
     rapport.tracks += 1;
   }
@@ -182,5 +201,6 @@
     const kwijt = (p['shaer:tracks'] || []).length - items.length;
     if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`);
-    werk.push({ soort: 'playlist', p, id, items });
+    const hoes = hoesTerug(files, p['shaer:coverFile'], werk);
+    werk.push({ soort: 'playlist', p, id, items, hoes });
     rapport.playlists += 1;
   }
@@ -395,5 +415,5 @@
           .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null,
             s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null,
-            s.t['shaer:coverUrl'] || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
+            s.hoes || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
             link('spotify'), link('youtube'), link('soundcloud'));
         continue;
@@ -403,5 +423,5 @@
                     VALUES (?,?,?,?,?,?,?)`)
           .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null,
-            s.p['shaer:coverUrl'] || null, s.p['shaer:kind'] || null);
+            s.hoes || null, s.p['shaer:kind'] || null);
         db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id);
         const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
Index: src/services/MigrationService.js
===================================================================
--- src/services/MigrationService.js	(revision 9529d7bda5061cdf8e8bbe1bd9d5cdfeae625f66)
+++ src/services/MigrationService.js	(revision 98acb9794ff741fff4649f5e6c62281e3dc3ae80)
@@ -219,4 +219,14 @@
 const AFBEELDING = /^image\//i;
 
+/** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
+function duurSeconden(v) {
+  if (v == null) return null;
+  if (typeof v === 'number') return Math.round(v) || null;
+  const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
+  if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
+  const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
+  return sec > 0 ? Math.round(sec) : null;
+}
+
 /**
  * De titel terugwinnen uit de content.
@@ -336,5 +346,6 @@
   const rapport = {
     bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
-    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0, waarschuwingen: [],
+    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0,
+    playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
   };
 
@@ -459,4 +470,5 @@
     // geldt voor de bestanden zelf, die anders achter de gated audio-route
     // blijven.
+    const trackKaart = new Map();   // bron-URI van een nummer -> ons nieuwe id
     const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
     const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
@@ -480,4 +492,16 @@
           continue;                       // dezelfde regel als bij de zip: geen bestand, geen track
         }
+        // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
+        // nummer aan met een verwijzing naar een plaatje dat er niet is.
+        let hoes = null;
+        const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
+        if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
+          const h = await haalBijlage(String(hoesUrl), {
+            safeFetch, mediaRoot, fs, path, maxBytes,
+            headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
+          }).catch(() => null);
+          if (h) { hoes = h.url; rapport.media++; }
+          else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
+        }
         const trackId = crypto.randomUUID();
         const mediaId = crypto.randomUUID();
@@ -485,9 +509,10 @@
           db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
             .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
-          db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, fedi_open)
-                      VALUES (?,?,?,?,?,?,?,0)`)
-            .run(trackId, site.id, a.name || 'zonder titel', a.artist || null, a.album || null,
-              Number(a.duration) || null, mediaId);
+          db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
+                      VALUES (?,?,?,?,?,?,?,?,0)`)
+            .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
+              duurSeconden(a.duration), mediaId, hoes);
           recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
+          trackKaart.set(String(a.id), trackId);
           rapport.tracksBinnen++;
         } catch (e) {
@@ -499,4 +524,45 @@
       rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
     }
+
+    // ── De playlists ──────────────────────────────────────────────
+    //
+    // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
+    // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
+    // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
+    // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
+    // plaat met gaten.
+    const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
+    if (plUrl && trackKaart.size) {
+      const coll = await getJson(site.slug, plUrl);
+      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
+      for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
+        const uri = typeof p === 'string' ? p : (p && p.id);
+        if (!uri) continue;
+        const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
+        if (!plc) { rapport.playlistsMislukt++; continue; }
+        const nummers = (plc.orderedItems || plc.items || [])
+          .map((x) => (x && typeof x === 'object' ? x.id : x))
+          .map((id) => trackKaart.get(String(id)))
+          .filter(Boolean);
+        if (!nummers.length) {
+          rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
+          continue;
+        }
+        const plId = crypto.randomUUID();
+        try {
+          db.prepare('INSERT INTO playlists (id, site_id, title, artist, year, kind) VALUES (?,?,?,?,?,?)')
+            .run(plId, site.id, plc.name || 'zonder titel', plc.attributedTo && plc.artist || plc.artist || null,
+              plc.year || null, plc['shaer:kind'] || null);
+          const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
+          nummers.forEach((tid, i) => ins.run(plId, tid, i));
+          rapport.playlistsBinnen++;
+          const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
+          if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
+        } catch (e) {
+          rapport.playlistsMislukt++;
+          rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
+        }
+      }
+    }
   } catch (e) {
     // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision 9529d7bda5061cdf8e8bbe1bd9d5cdfeae625f66)
+++ src/services/music/index.js	(revision 98acb9794ff741fff4649f5e6c62281e3dc3ae80)
@@ -22,5 +22,10 @@
      m.filename, m.storage_path, m.mime_type, m.size`;
 
-export function playlistOpenTracks(playlistId) {
+/**
+ * `alles` net als bij siteOpenTracks (FEP-1580): zonder die tak krijgt de
+ * instantie waar je naartoe verhuist een playlist met gaten erin, want alleen
+ * de opengezette nummers zitten erin. Een halve plaat is geen plaat.
+ */
+export function playlistOpenTracks(playlistId, { alles = false } = {}) {
   return db.prepare(
     `SELECT ${TRACK_KOLOMMEN}
@@ -28,5 +33,5 @@
      JOIN audio_tracks t ON t.id = pt.track_id
      JOIN media m ON m.id = t.media_id
-     WHERE pt.playlist_id = ? AND t.fedi_open = 1
+     WHERE pt.playlist_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
      ORDER BY pt.position`
   ).all(playlistId);
Index: test/archive-audio.test.js
===================================================================
--- test/archive-audio.test.js	(revision 9529d7bda5061cdf8e8bbe1bd9d5cdfeae625f66)
+++ test/archive-audio.test.js	(revision 98acb9794ff741fff4649f5e6c62281e3dc3ae80)
@@ -188,3 +188,49 @@
 });
 
+
+test('de hoes van een nummer reist mee als BESTAND, niet als losse verwijzing', () => {
+  // Robin na de tweede ronde: "de audio tracks hadden images, die zijn niet
+  // meegegaan". cover_url ging wel mee als string en het bestand niet, dus kwam
+  // een nummer aan met een verwijzing naar een plaatje dat er niet was.
+  // Dezelfde fout als bij de audio zelf, een laag hoger.
+  leeg();
+  const hoesDir = path.join(MEDIA, 'hoes');
+  fs.mkdirSync(hoesDir, { recursive: true });
+  const PNG = Buffer.from('nep-png-bytes');
+  fs.writeFileSync(path.join(hoesDir, 'a.png'), PNG);
+  track('h1', 'Met hoes');
+  db.prepare("UPDATE audio_tracks SET cover_url = '/media/hoes/a.png' WHERE id = 'h1'").run();
+  db.prepare("INSERT INTO playlists (id, site_id, title, cover_url) VALUES ('plh','s1','Plaat','/media/hoes/a.png')").run();
+  db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('plh','h1',0)").run();
+
+  const uit = AX.buildArchive('me');
+  const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems[0];
+  assert.ok(rij['shaer:coverFile'], 'de hoes hoort een plek in het archief te hebben');
+  assert.deepEqual(uit.files.get(rij['shaer:coverFile']), PNG, 'met de echte bytes erin');
+  const pl = JSON.parse(uit.files.get('playlists.json').toString('utf8')).orderedItems[0];
+  assert.ok(pl['shaer:coverFile'], 'en de hoes van de plaat ook');
+
+  leeg();
+  AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  const t = db.prepare("SELECT cover_url FROM audio_tracks WHERE id = 'h1'").get();
+  assert.ok(t.cover_url, 'na de import wijst het nummer naar een hoes');
+  const opSchijf = path.join(MEDIA, t.cover_url.replace(/^\/media\//, ''));
+  assert.deepEqual(fs.readFileSync(opSchijf), PNG, 'en die staat er ook echt');
+});
+
+test('een hoes die niet in het archief zit levert GEEN kapotte verwijzing op', () => {
+  // Liever geen hoes dan een <img> die 404't. Dezelfde regel als bij de tracks.
+  leeg();
+  track('h2', 'Hoes zoek');
+  db.prepare("UPDATE audio_tracks SET cover_url = '/media/bestaat/niet.png' WHERE id = 'h2'").run();
+  const uit = AX.buildArchive('me');
+  const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems[0];
+  assert.equal(rij['shaer:coverFile'], undefined);
+
+  leeg();
+  AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  assert.equal(db.prepare("SELECT cover_url FROM audio_tracks WHERE id = 'h2'").get().cover_url, null,
+    'geen verwijzing naar een plaatje dat er niet is');
+});
+
 test.after(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* niets */ } });
