Index: src/config/paths.js
===================================================================
--- src/config/paths.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/config/paths.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -34,2 +34,39 @@
   return path.resolve(process.env[envVar] || path.join(MEDIA_ROOT, sub));
 }
+
+/**
+ * Waar de gehoste audio staat.
+ *
+ * BEWUST BUITEN MEDIA_ROOT: de publieke /media-handler mag er niet bij, elke
+ * fetch loopt via de gated route in routes/audio.js. Diezelfde route resolvet
+ * met AUDIO_DIR + bestandsnaam, en negeert media.storage_path volledig.
+ */
+export const AUDIO_ROOT = path.resolve(
+  process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio'),
+);
+
+/**
+ * Het echte pad van een audiobestand, op DEZELFDE manier als de speler het zoekt.
+ *
+ * Dit bestaat omdat die twee uit elkaar liepen en dat een verhuizing sloopte.
+ * Op sound-fabrics.com wees media.storage_path voor 124 van de 139 tracks nog
+ * naar /srv/prutfolio/storage/audio (van voor de dataverhuizing), terwijl de
+ * bestanden allang op ~/data/prutfolio/audio stonden. De site merkte er niets
+ * van, want de speler kijkt alleen naar de bestandsnaam. De exporter las wel
+ * storage_path, vond niets, en liet 124 nummers stil achter.
+ *
+ * Volgorde: eerst zoals de speler kijkt (bestandsnaam in AUDIO_ROOT), dan pas
+ * het opgeslagen pad. Zo klopt de export met wat de gebruiker hoort, en niet
+ * met wat de database ooit dacht.
+ *
+ * @returns {string|null} een bestaand pad, of null
+ */
+export function resolveAudioPath(storagePath, fs) {
+  const s = String(storagePath || '');
+  if (!s) return null;
+  const kandidaten = [path.join(AUDIO_ROOT, path.basename(s)), path.resolve(s)];
+  for (const p of kandidaten) {
+    try { if (fs.statSync(p).isFile()) return p; } catch { /* volgende kandidaat */ }
+  }
+  return null;
+}
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/routes/activitypub.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -1043,4 +1043,19 @@
 });
 
+/**
+ * Mag deze aanvrager alles van `slug` zien? Waar bij de eigenaar zelf, en waar
+ * voor de actor waar `slug` naartoe verhuisd is (FEP-1580, Source Instance).
+ *
+ * Eén plek voor die vraag, want hij komt op meerdere collecties terug en twee
+ * antwoorden op dezelfde vraag lopen vroeg of laat uiteen.
+ */
+async function magAlles(req, slug) {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (auth && auth.site.slug === slug) return true;
+  if (!req.headers['signature']) return false;
+  const v = await AP.verifyRequest(req).catch(() => null);
+  return !!(v && v.id && AP.isMoveTarget(slug, v.id));
+}
+
 // ── FEP-1580: de vertaaltabel van een verhuizing ──────────────────
 //
@@ -1115,8 +1130,8 @@
 // stap 3). Een playlist is een keuze hieruit; deze collectie is alles wat de
 // artiest heeft opengezet, ook wat in geen enkele playlist staat.
-router.get('/ap/users/:slug/tracks', (req, res) => {
+router.get('/ap/users/:slug/tracks', async (req, res) => {
   const site = publicSite(req.params.slug);
   if (!site) return res.status(404).end();
-  AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id)));
+  AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id, { alles: await magAlles(req, site.slug) })));
 });
 
Index: src/routes/admin-migrate.js
===================================================================
--- src/routes/admin-migrate.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/routes/admin-migrate.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -29,7 +29,7 @@
 import { requireGod } from '../middleware/auth.js';
 import ActivityPubService from '../services/ActivityPubService.js';
-import { safeFetch, signedGetJson, noteId, noteVisibility } from '../services/ActivityPubService.js';
+import { safeFetch, signedGetJson, signedGetHeaders, noteId, noteVisibility } from '../services/ActivityPubService.js';
 import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
-import { MEDIA_ROOT } from '../config/paths.js';
+import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
 import * as Migration from '../services/MigrationService.js';
 import { buildArchive, zipArchive } from '../services/ArchiveExportService.js';
@@ -111,5 +111,5 @@
       sourceUri: opgegeven || null,
       deps: {
-        getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, fs, path, noteId, noteVisibility,
+        getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, audioRoot: AUDIO_ROOT, fs, path, noteId, noteVisibility, signHeaders: signedGetHeaders,
         sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
       },
Index: src/routes/audio.js
===================================================================
--- src/routes/audio.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/routes/audio.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -30,4 +30,5 @@
 import db from '../config/database.js';
 import { recordPlay } from '../services/StatsService.js';
+import AP from '../services/ActivityPubService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -71,8 +72,36 @@
 }
 
-router.get('/stream/:filename', (req, res) => {
+/**
+ * FEP-1580: de instantie waar dit account naartoe verhuisd is mag ALLE audio
+ * ophalen, ook wat niet fedi_open is.
+ *
+ * Zonder deze tak ziet de nieuwe Klonkt de tracklijst wel en krijgt hij de
+ * bestanden niet, en dan verhuis je een bibliotheek met alleen titels. Dat is
+ * precies de halve waarheid die deze hele ronde moest opruimen.
+ *
+ * Smal gehouden: een geldige handtekening, van precies de actor in moved_to, en
+ * alleen voor een bestand dat van DIE site is. moved_to komt er alleen te staan
+ * als de doel-actor ons in alsoKnownAs had, dus er heeft iemand met beheer aan
+ * beide kanten ja gezegd.
+ */
+async function isMoveTargetAudio(req, filename) {
+  if (!req.headers['signature'] || !filename) return false;
+  let rij;
+  try {
+    rij = db.prepare(`SELECT s.slug FROM audio_tracks t
+                        JOIN media m ON t.media_id = m.id
+                        JOIN sites s ON s.id = t.site_id
+                       WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1`)
+      .get(filename, `%${filename}`);
+  } catch { return false; }
+  if (!rij || !rij.slug) return false;
+  const v = await AP.verifyRequest(req).catch(() => null);
+  return !!(v && v.id && AP.isMoveTarget(rij.slug, v.id));
+}
+
+router.get('/stream/:filename', async (req, res) => {
   const { filename } = req.params;
 
-  if (!isAllowedAudioRequest(req, filename)) {
+  if (!isAllowedAudioRequest(req, filename) && !(await isMoveTargetAudio(req, filename))) {
     return res.status(403).send('Direct access not allowed');
   }
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/services/ActivityPubService.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -5416,17 +5416,32 @@
  * (request-target) host date, the set verifyRequest checks.
  */
+/**
+ * De handtekening-headers voor een GET als `slug`. Losgetrokken uit
+ * signedGetJson omdat een verhuizing ook BYTES moet kunnen ophalen (FEP-1580:
+ * gehoste audio zit achter dezelfde poort als de rest, en een ongetekende fetch
+ * krijgt daar terecht een 403).
+ */
+export function signedGetHeaders(slug, url, accept = 'application/activity+json') {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !slug) return null;
+  const me = actorId(base, slug);
+  const keys = getOrCreateKeys(slug);
+  const u = new URL(url);
+  const date = new Date().toUTCString();
+  const target = `${u.pathname}${u.search || ''}`;
+  const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
+  const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
+  return {
+    Accept: accept,
+    Date: date,
+    Signature: `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`,
+  };
+}
+
 export async function signedGetJson(slug, url, onStatus) {
   try {
-    const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-    if (!base || !slug) return apGetJson(url);
-    const me = actorId(base, slug);
-    const keys = getOrCreateKeys(slug);
-    const u = new URL(url);
-    const date = new Date().toUTCString();
-    const target = `${u.pathname}${u.search || ''}`;
-    const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
-    const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
-    const sig = `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`;
-    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json', Date: date, Signature: sig } });
+    const headers = signedGetHeaders(slug, url);
+    if (!headers) return apGetJson(url);
+    const r = await safeFetch(url, { headers });
     // De status doorgeven aan wie erom vroeg: null alleen zegt "het lukte
     // niet", en dat is te weinig om een WEIGERING van een STORING te
@@ -6757,5 +6772,5 @@
   // aanroept een 500 en lijkt het alsof de poort dicht staat terwijl hij
   // ontbreekt (precies hoe movedLock zich een dag eerder verstopte).
-  isMoveTarget, signedGetJson,
+  isMoveTarget, signedGetJson, signedGetHeaders,
   AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection,
   deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE,
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/services/ArchiveExportService.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -19,7 +19,14 @@
 import crypto from 'crypto';
 import db from '../config/database.js';
-import { MEDIA_ROOT } from '../config/paths.js';
-
-export const FORMAT_VERSION = 1;
+import { MEDIA_ROOT, resolveAudioPath } from '../config/paths.js';
+
+// v2: audio zit er eindelijk echt in. Tot v1 kon dat niet: gehoste audio staat
+// BUITEN MEDIA_ROOT (eigen gated route, zie routes/audio.js), en het archief
+// droeg alleen bestanden onder media/. De exporter rekende er met path.relative
+// een /media/../audio/x.mp3 van, en de importer weigerde dat pad terecht. Er
+// stond dus wel een track in de database van de nieuwe site, maar nooit een
+// bestand. v2 heeft een eigen audio/-gebied, exporteert de HELE bibliotheek in
+// plaats van alleen wat in een bericht staat, en neemt de playlists mee.
+export const FORMAT_VERSION = 2;
 
 /** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
@@ -122,11 +129,8 @@
     }
   } catch { /* kapotte kolom blokkeert de export niet */ }
-  // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder.
-  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
-    try {
-      const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]);
-      if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track');
-    } catch { /* geen audio-tabellen: niets te doen */ }
-  }
+  // Gehoste audio staat hier NIET meer bij. Die leeft buiten MEDIA_ROOT en gaat
+  // sinds v2 via het audio/-gebied (zie audioBibliotheek). De oude regel rekende
+  // met path.relative een pad naar buiten MEDIA_ROOT uit, en dat kon nooit
+  // aankomen: de importer weigert zo'n pad, terecht.
   return uit;
 }
@@ -141,16 +145,14 @@
  * herstel op niets terug.
  */
-function audioOf(post, attachments) {
+function audioOf(post, audioKaart) {
   const uit = [];
   for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
     try {
-      const t = db.prepare('SELECT t.*, md.storage_path FROM audio_tracks t LEFT JOIN media md ON md.id = t.media_id WHERE t.id = ?').get(m[1]);
+      const t = db.prepare('SELECT * FROM audio_tracks WHERE id = ?').get(m[1]);
       if (!t) continue;
-      let bestand;
-      if (t.storage_path) {
-        const rel = `/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`;
-        const bij = attachments.find((a) => String(a['shaer:originalUrl'] || '').endsWith(rel));
-        bestand = bij ? bij.url : undefined;
-      }
+      // Sinds v2 wijst dit naar het audio/-gebied. Staat de track er niet in
+      // (bestand onvindbaar), dan blijft het veld LEEG in plaats van naar een
+      // bijlage te wijzen die er niet is.
+      const bestand = audioKaart.get(t.id) || undefined;
       uit.push({
         'shaer:ref': `[[track:${t.id}]]`,
@@ -165,6 +167,96 @@
 }
 
+/**
+ * De HELE audiobibliotheek, plus de playlists.
+ *
+ * Tot v1 ging alleen mee wat met [[track:]] in een bericht stond. Op
+ * sound-fabrics.com waren dat er 14 van de 140, en de 11 playlists gingen
+ * helemaal niet mee. Een verhuizing die je bibliotheek achterlaat is geen
+ * verhuizing.
+ *
+ * Het bestand wordt gezocht met resolveAudioPath, dus op DEZELFDE manier als de
+ * speler het zoekt. Dat verschil was de stille moordenaar: 124 van de 139
+ * storage_paths waren verouderd na een dataverhuizing, de site speelde gewoon
+ * door, en de export liet ze weg zonder dat iemand het merkte.
+ *
+ * @returns {Map<string,string>} trackId -> pad in het archief
+ */
+function audioBibliotheek(site, bestanden, tellingen, ontbrekend) {
+  const kaart = new Map();
+  let tracks = [];
+  try {
+    tracks = db.prepare(`SELECT t.*, m.storage_path, m.mime_type FROM audio_tracks t
+                          LEFT JOIN media m ON m.id = t.media_id
+                         WHERE t.site_id = ?
+                         ORDER BY COALESCE(t.position, 999999), t.created_at, t.id`).all(site.id);
+  } catch { return kaart; }              // installatie zonder audio-tabellen
+  if (!tracks.length) return kaart;
+
+  const items = [];
+  for (const t of tracks) {
+    const schijf = resolveAudioPath(t.storage_path, fs);
+    let naam = null;
+    let hash = null;
+    if (schijf) {
+      try {
+        const bytes = fs.readFileSync(schijf);
+        hash = sha256(bytes);
+        const ext = (path.extname(schijf).slice(1) || 'mp3').toLowerCase();
+        naam = `audio/${hash}.${ext}`;
+        if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.audio += 1; }
+        kaart.set(t.id, naam);
+      } catch { naam = null; }           // onleesbaar telt als ontbrekend, niet als stilte
+    }
+    if (!naam) {
+      tellingen.audioMissing += 1;
+      ontbrekend.push({ track: t.title || t.id, url: t.storage_path || '(geen mediarij)' });
+    }
+    items.push({
+      id: t.id, name: t.title || '', artist: t.artist || undefined, album: t.album || undefined,
+      duration: t.duration || undefined, position: t.position ?? undefined,
+      credit: t.credit || undefined, license: t.license || undefined,
+      'shaer:coverUrl': t.cover_url || undefined,
+      'shaer:downloadable': t.downloadable ? 1 : 0,
+      'shaer:fediOpen': t.fedi_open ? 1 : 0,
+      'shaer:mediaType': t.mime_type || 'audio/mpeg',
+      'shaer:file': naam || undefined,
+      'shaer:sha256': hash || undefined,
+      // Derde staat, net als bij media: we weten DAT het bestond en waar het
+      // stond. Stil weglaten zou een leugen zijn, en de importer moet hierop
+      // kunnen weigeren in plaats van een track zonder bestand aan te maken.
+      'shaer:availability': naam ? 'included' : 'missing',
+      'shaer:originalPath': naam ? undefined : (t.storage_path || undefined),
+      url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
+    });
+  }
+  bestanden.set('tracks.json', Buffer.from(stableJson({
+    '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
+    type: 'OrderedCollection', 'shaer:archive': true, totalItems: items.length, orderedItems: items,
+  }), 'utf8'));
+  tellingen.tracks = items.length;
+
+  // Playlists: de volgorde IS de playlist, dus die moet expliciet mee.
+  try {
+    const pls = db.prepare('SELECT * FROM playlists WHERE site_id = ? ORDER BY created_at, id').all(site.id);
+    if (pls.length) {
+      const lijst = pls.map((p) => ({
+        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: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 })),
+      }));
+      bestanden.set('playlists.json', Buffer.from(stableJson({
+        '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
+        type: 'OrderedCollection', 'shaer:archive': true, totalItems: lijst.length, orderedItems: lijst,
+      }), 'utf8'));
+      tellingen.playlists = lijst.length;
+    }
+  } catch { /* geen playlist-tabellen */ }
+
+  return kaart;
+}
+
 /** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
-function postObject(post, site, origin, attachments) {
+function postObject(post, site, origin, attachments, audioKaart) {
   const heeftTitel = !!(post.title && String(post.title).trim());
   const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
@@ -220,5 +312,5 @@
     'shaer:coverAlt': post.cover_alt || undefined,
     'shaer:viewCount': post.view_count || undefined,
-    'shaer:audio': audioOf(post, attachments),
+    'shaer:audio': audioOf(post, audioKaart),
   };
 }
@@ -362,6 +454,10 @@
 
   const bestanden = new Map();       // pad -> Buffer
-  const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
+  const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0, audio: 0, audioMissing: 0, tracks: 0, playlists: 0 };
   const ontbrekend = [];             // voor de rapportage van de beller
+
+  // 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);
 
   // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
@@ -404,5 +500,5 @@
     }
 
-    const obj = postObject(post, site, origin, attachments);
+    const obj = postObject(post, site, origin, attachments, audioKaart);
     bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
     bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/services/ArchiveImportService.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -24,5 +24,5 @@
 import { randomUUID } from 'crypto';
 import db from '../config/database.js';
-import { MEDIA_ROOT } from '../config/paths.js';
+import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
 import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
 import * as Migration from './MigrationService.js';
@@ -120,4 +120,73 @@
 
 /**
+ * De audiobibliotheek terugzetten (formaat v2).
+ *
+ * EEN REGEL DIE HIER ALLES BEPAALT: geen bestand, geen track. Dat klinkt
+ * vanzelfsprekend en was het niet. De oude per-post-tak maakte een
+ * audio_tracks-rij aan zodra er metadata was, ook als de bytes ontbraken. Op
+ * soundfabrics.nl leverde dat 13 nummers op die in de lijst stonden en 404'den
+ * bij het afspelen. Dat is erger dan ontbreken: het ziet eruit alsof de
+ * verhuizing gelukt is, dus je gooit de oude instantie weg.
+ *
+ * De bestanden gaan naar AUDIO_ROOT en niet onder MEDIA_ROOT, want daar hoort
+ * gehoste audio: de publieke /media-handler mag er niet bij (routes/audio.js).
+ *
+ * @returns {Array} de schrijfopdrachten; de beller voert ze in zijn transactie uit
+ * (en bij een droogloop dus niet, maar het verslag klopt wel)
+ */
+function tracksTerug(files, site, rapport) {
+  const buf = files.get('tracks.json');
+  if (!buf) return [];
+  let coll;
+  try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('tracks.json is onleesbaar'); return []; }
+  if (coll['shaer:archive'] !== true) { rapport.waarschuwingen.push('tracks.json: niet gemarkeerd als archief, overgeslagen'); return []; }
+
+  const werk = [];
+  for (const t of (coll.orderedItems || [])) {
+    const id = String(t.id || '').trim();
+    if (!id) continue;
+    const bestand = t['shaer:file'];
+    const bytes = bestand ? files.get(bestand) : null;
+    if (!bytes || !bytes.length) {
+      rapport.tracksMissing += 1;
+      rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`);
+      continue;                       // de hele regel van deze functie
+    }
+    // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
+    // zoekt op bestandsnaam in AUDIO_ROOT, dus dit is meteen het pad dat werkt.
+    const naam = path.basename(String(bestand));
+    if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) {
+      rapport.waarschuwingen.push(`${t.name || id}: onbruikbare bestandsnaam, overgeslagen`);
+      continue;
+    }
+    werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam });
+    rapport.tracks += 1;
+  }
+  return werk;
+}
+
+/** De playlists terug, inclusief hun volgorde: die volgorde IS de playlist. */
+function playlistsTerug(files, site, rapport, bekendeTracks) {
+  const buf = files.get('playlists.json');
+  if (!buf) return [];
+  let coll;
+  try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('playlists.json is onleesbaar'); return []; }
+  if (coll['shaer:archive'] !== true) return [];
+  const werk = [];
+  for (const p of (coll.orderedItems || [])) {
+    const id = String(p.id || '').trim();
+    if (!id) continue;
+    // Alleen verwijzen naar tracks die er echt gekomen zijn, anders staat er
+    // straks een playlist vol gaten die niemand kan afspelen.
+    const items = (p['shaer:tracks'] || []).filter((x) => bekendeTracks.has(String(x && x.id)));
+    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 });
+    rapport.playlists += 1;
+  }
+  return werk;
+}
+
+/**
  * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
  *
@@ -167,4 +236,5 @@
     posts: 0, overgeslagen: 0, overschreven: 0,
     replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
+    tracks: 0, tracksMissing: 0, playlists: 0,
   };
 
@@ -275,4 +345,10 @@
     }
   }
+
+  // De audiobibliotheek. Telt ook in een droogloop mee in het verslag, want
+  // "hoeveel nummers komen er" is precies wat je wilt weten voor je besluit.
+  const trackWerk = tracksTerug(files, site, rapport);
+  const bekendeTracks = new Set(trackWerk.map((w) => w.id));
+  const playlistWerk = playlistsTerug(files, site, rapport, bekendeTracks);
 
   if (opts.dryRun) return rapport;
@@ -292,5 +368,5 @@
 
   db.transaction(() => {
-    for (const s of schrijf) {
+    for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) {
       if (s.soort === 'media') {
         fs.mkdirSync(path.dirname(s.doel), { recursive: true });
@@ -301,4 +377,34 @@
         insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
           s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
+        continue;
+      }
+      if (s.soort === 'track') {
+        // Bestand eerst, dan pas de rijen. Faalt het schrijven, dan gooit dit en
+        // rolt de hele transactie terug: liever geen import dan een track zonder
+        // geluid, want dat is precies de val waar dit uit voortkomt.
+        fs.mkdirSync(path.dirname(s.doel), { recursive: true });
+        fs.writeFileSync(s.doel, s.bytes);
+        const mediaId = randomUUID();
+        db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+          .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
+        const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null;
+        db.prepare(`INSERT OR REPLACE INTO audio_tracks
+            (id, site_id, title, artist, album, duration, media_id, position, credit, license,
+             cover_url, downloadable, fedi_open, link_spotify, link_youtube, link_soundcloud)
+          VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
+          .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,
+            link('spotify'), link('youtube'), link('soundcloud'));
+        continue;
+      }
+      if (s.soort === 'playlist') {
+        db.prepare(`INSERT OR REPLACE INTO playlists (id, site_id, title, artist, year, cover_url, kind)
+                    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);
+        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 (?,?,?)');
+        s.items.forEach((it, i) => insPT.run(s.id, String(it.id), it.position ?? i));
         continue;
       }
@@ -338,26 +444,10 @@
         view_count: o['shaer:viewCount'] || 0,
       });
-      // Gehoste audio terug: [[track:]] in de content valt anders op niets terug.
-      for (const t of (o['shaer:audio'] || [])) {
-        const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, '');
-        if (!trackId) continue;
-        let mediaId = null;
-        const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
-        const doel = bij && veiligMediaPad(bestemming(bij));
-        if (doel) {
-          mediaId = randomUUID();
-          try {
-            db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
-              .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel);
-          } catch { mediaId = null; }
-        }
-        try {
-          db.prepare(`INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, credit, license, link_spotify, link_youtube, link_soundcloud)
-                      VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
-            .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null,
-              mediaId, t.credit || null, t.license || null,
-              ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null));
-        } catch { /* geen audio-tabellen op deze installatie */ }
-      }
+      // De per-post audio-tak is weg. Tracks komen sinds v2 uit tracks.json,
+      // dat de HELE bibliotheek draagt in plaats van alleen wat in een bericht
+      // stond. Hier stond bovendien de fout die soundfabrics.nl opleverde: deze
+      // lus maakte een audio_tracks-rij aan ZONDER te kijken of het bestand er
+      // wel was, dus je kreeg 13 nummers die bestonden, in de lijst stonden, en
+      // 404'den zodra je op play drukte. Zie tracksTerug hieronder.
     }
 
Index: src/services/MigrationService.js
===================================================================
--- src/services/MigrationService.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/services/MigrationService.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -248,6 +248,9 @@
  * moet stranden, ook als die bron "van onszelf" is.
  */
-async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes }) {
-  const r = await safeFetch(url, { headers: { accept: '*/*' } }).catch(() => null);
+async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null }) {
+  // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
+  // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
+  // niet zien dat wij de doel-actor van zijn Move zijn.
+  const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
   if (!r || !r.ok) return null;
   const buf = Buffer.from(await r.arrayBuffer());
@@ -261,6 +264,9 @@
   })();
   const naam = `${crypto.randomUUID()}.${ext}`;
-  const rel = `migrated/${naam}`;
-  const abs = path.join(mediaRoot, 'migrated', naam);
+  // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
+  // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
+  // in mappen eronder.
+  const rel = submap ? `${submap}/${naam}` : naam;
+  const abs = submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam);
   fs.mkdirSync(path.dirname(abs), { recursive: true });
   fs.writeFileSync(abs, buf);
@@ -294,4 +300,5 @@
     // privacygrens en niet een weergavedetail.
     noteVisibility = () => 'followers',
+    audioRoot = null, signHeaders = null,
   } = deps;
   const zichtbaarheid = noteVisibility;
@@ -329,5 +336,5 @@
   const rapport = {
     bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
-    blocks: 0, waarschuwingen: [],
+    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0, waarschuwingen: [],
   };
 
@@ -441,4 +448,55 @@
     }
     if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
+
+    // ── De muziekbibliotheek ──────────────────────────────────────
+    //
+    // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
+    // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
+    // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
+    //
+    // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
+    // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
+    // geldt voor de bestanden zelf, die anders achter de gated audio-route
+    // blijven.
+    const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
+    const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
+    if (tracksUrl && safeFetch && fs && path && audioRoot) {
+      const coll = await getJson(site.slug, tracksUrl);
+      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
+      for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
+        const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
+        if (!a || !a.id) continue;
+        if (a.type && a.type !== 'Audio') continue;
+        if (alGemigreerd(site.slug, a.id)) { rapport.overgeslagenTracks++; continue; }
+        const bron = a.url && (typeof a.url === 'string' ? a.url : (Array.isArray(a.url) ? (a.url[0] && (a.url[0].href || a.url[0])) : a.url.href));
+        if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
+        const g = await haalBijlage(String(bron), {
+          safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
+          headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
+        }).catch(() => null);
+        if (!g) {
+          rapport.tracksMislukt++;
+          rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
+          continue;                       // dezelfde regel als bij de zip: geen bestand, geen track
+        }
+        const trackId = crypto.randomUUID();
+        const mediaId = crypto.randomUUID();
+        try {
+          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);
+          recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
+          rapport.tracksBinnen++;
+        } catch (e) {
+          rapport.tracksMislukt++;
+          rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
+        }
+      }
+    } else if (tracksUrl) {
+      rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
+    }
   } 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 fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ src/services/music/index.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -40,9 +40,18 @@
  * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
  */
-export function siteOpenTracks(siteId) {
+/**
+ * `alles` bestaat voor FEP-1580. Bij een verhuizing behandelt de bron een
+ * ondertekend verzoek van de DOEL-actor als zichzelf, en dat geldt hier net zo
+ * goed als bij de outbox. Zonder deze tak neemt een verhuizing alleen je
+ * opengezette nummers mee en blijft je hele gesloten bibliotheek achter op een
+ * domein dat je gaat opzeggen. De poort blijft verder dicht: alleen die ene
+ * actor, en alleen omdat moveAccount() een terugverwijzing eiste voordat
+ * moved_to er kwam te staan.
+ */
+export function siteOpenTracks(siteId, { alles = false } = {}) {
   return db.prepare(
     `SELECT ${TRACK_KOLOMMEN}
      FROM audio_tracks t JOIN media m ON m.id = t.media_id
-     WHERE t.site_id = ? AND t.fedi_open = 1
+     WHERE t.site_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
      ORDER BY t.position, t.created_at, t.id`
   ).all(siteId);
