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);
Index: test/archive-audio.test.js
===================================================================
--- test/archive-audio.test.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
+++ test/archive-audio.test.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -0,0 +1,190 @@
+// Audio overleeft een verhuizing. Formaat v2.
+//
+// Dit bestand bestaat door wat er op soundfabrics.nl gebeurde: 36 berichten
+// kwamen goed over, 14 van de 140 nummers stonden er, en 13 daarvan speelden
+// niet af. Drie oorzaken, alle drie hier vastgelegd:
+//
+//   1. Gehoste audio leeft BUITEN MEDIA_ROOT (eigen gated route). Het archief
+//      droeg alleen bestanden onder media/, dus de exporter rekende er een
+//      /media/../audio/x.mp3 van en de importer weigerde dat pad. Terecht: het
+//      wijst buiten de mediamap. Gevolg was wel dat audio nooit kon aankomen.
+//   2. De importer maakte een audio_tracks-rij aan ZONDER te kijken of het
+//      bestand er was. Een nummer dat in de lijst staat en 404't bij play is
+//      erger dan een nummer dat ontbreekt: het ziet eruit alsof het gelukt is,
+//      dus je gooit je oude instantie weg.
+//   3. Alleen audio die met [[track:]] in een bericht stond ging mee. Losse
+//      nummers en playlists bleven achter (126 respectievelijk 11 stuks).
+//
+// Een verhuizing die je bibliotheek achterlaat is geen verhuizing.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-audio-'));
+const MEDIA = path.join(TMP, 'media');
+const AUDIO = path.join(TMP, 'audio');
+fs.mkdirSync(MEDIA, { recursive: true });
+fs.mkdirSync(AUDIO, { recursive: true });
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://oud.test';
+process.env.MEDIA_PATH = MEDIA;
+process.env.AUDIO_PATH = AUDIO;      // MOET voor de import van paths.js staan
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
+const AX = await import('../src/services/ArchiveExportService.js');
+const AI = await import('../src/services/ArchiveImportService.js');
+const { AUDIO_ROOT } = await import('../src/config/paths.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
+
+const BYTES = (s) => Buffer.from(`RIFF${s}`.padEnd(64, '.'));
+
+/** Schone lei: tabellen leeg en de audiomap leeg. Tests lekken anders in elkaar. */
+function leeg() {
+  for (const t of ['playlist_tracks', 'playlists', 'audio_tracks', 'media']) {
+    try { db.prepare(`DELETE FROM ${t}`).run(); } catch { /* tabel bestaat niet */ }
+  }
+  for (const f of fs.readdirSync(AUDIO)) { try { fs.unlinkSync(path.join(AUDIO, f)); } catch { /* niets */ } }
+}
+
+/** Een nummer met zijn bestand op de plek waar de SPELER het zoekt. */
+function track(id, titel, { padInDb = null, schrijf = true } = {}) {
+  const naam = `${id}.mp3`;
+  const echt = path.join(AUDIO, naam);
+  if (schrijf) fs.writeFileSync(echt, BYTES(id));
+  db.prepare('INSERT OR REPLACE INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+    .run(`m-${id}`, 's1', naam, 'audio/mpeg', 1, padInDb || echt);
+  db.prepare('INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, media_id) VALUES (?,?,?,?,?)')
+    .run(id, 's1', titel, 'Robin', `m-${id}`);
+  return echt;
+}
+
+test('een nummer met een VEROUDERD pad in de database gaat gewoon mee', () => {
+  leeg();
+  // Dit is de stille moordenaar. Op sound-fabrics.com wees storage_path voor
+  // 124 van de 139 nummers nog naar /srv/prutfolio/storage/audio, van voor een
+  // dataverhuizing. De speler merkte niets (die zoekt op bestandsnaam in
+  // AUDIO_ROOT), de exporter vond niets en liet ze weg. Zonder klacht.
+  track('oudpad', 'Verouderd pad', { padInDb: '/srv/ergens/anders/oudpad.mp3' });
+  const uit = AX.buildArchive('me');
+  const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems.find((x) => x.id === 'oudpad');
+  assert.equal(rij['shaer:availability'], 'included',
+    'de exporter hoort te zoeken zoals de speler zoekt, niet zoals de database onthoudt');
+  assert.deepEqual(uit.files.get(rij['shaer:file']), BYTES('oudpad'));
+});
+
+test('de hele rondgang: exporteren, importeren, en het geluid staat er', () => {
+  leeg();
+  track('a', 'Nummer A');
+  track('b', 'Nummer B');
+  const uit = AX.buildArchive('me');
+  assert.equal(uit.counts.tracks, 2);
+
+  // Een LEGE tweede installatie, met zijn eigen mappen.
+  const TMP2 = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-audio2-'));
+  const doelAudio = path.join(TMP2, 'audio');
+  fs.mkdirSync(doelAudio, { recursive: true });
+
+  // AUDIO_ROOT ligt vast bij import, dus we importeren in DEZELFDE map en
+  // controleren daarna dat het bestand er staat onder de naam uit het archief.
+  leeg();
+  assert.equal(fs.readdirSync(AUDIO).length, 0, 'schone lei');
+
+  const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  assert.equal(rapport.tracks, 2, 'beide nummers komen terug');
+  assert.equal(rapport.tracksMissing, 0);
+
+  const rijen = db.prepare('SELECT t.id, t.title, m.storage_path, m.size FROM audio_tracks t JOIN media m ON m.id = t.media_id ORDER BY t.id').all();
+  assert.equal(rijen.length, 2);
+  for (const r of rijen) {
+    assert.ok(fs.existsSync(r.storage_path), `${r.title}: het bestand hoort er ECHT te staan`);
+    assert.ok(r.size > 0, 'en de grootte klopt, geen 0 zoals bij de kapotte import');
+    assert.ok(r.storage_path.startsWith(path.resolve(AUDIO_ROOT) + path.sep),
+      'audio hoort in AUDIO_ROOT, niet onder de publieke mediamap');
+  }
+  fs.rmSync(TMP2, { recursive: true, force: true });
+});
+
+test('een nummer zonder bestand wordt NIET aangemaakt', () => {
+  // De kern van de klacht: 13 nummers die bestonden en niet speelden. Liever
+  // eerlijk weg dan zichtbaar kapot.
+  leeg();
+  track('zoek', 'Zoekgeraakt', { padInDb: '/bestaat/echt/niet.mp3', schrijf: false });
+  const uit = AX.buildArchive('me');
+  assert.equal(uit.counts.audioMissing, 1);
+
+  leeg();
+  const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  assert.equal(rapport.tracks, 0);
+  assert.equal(rapport.tracksMissing, 1);
+  assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0,
+    'geen rij zonder geluid: dat is precies de val die soundfabrics.nl opleverde');
+  assert.ok(rapport.waarschuwingen.some((w) => /Zoekgeraakt/.test(w)),
+    'en de gebruiker hoort te weten welk nummer er niet is');
+});
+
+test('playlists komen mee, met hun volgorde', () => {
+  leeg();
+
+  track('p1', 'Eerste');
+  track('p2', 'Tweede');
+  track('p3', 'Derde');
+  db.prepare("INSERT INTO playlists (id, site_id, title, artist, kind) VALUES ('pl1','s1','Mijn plaat','Robin','album')").run();
+  // Bewust NIET op alfabet: de volgorde is de hele playlist.
+  db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('pl1','p3',0),('pl1','p1',1),('pl1','p2',2)").run();
+
+  const uit = AX.buildArchive('me');
+  assert.equal(uit.counts.playlists, 1);
+
+  leeg();
+
+  const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  assert.equal(rapport.playlists, 1);
+  const pl = db.prepare("SELECT * FROM playlists WHERE id = 'pl1'").get();
+  assert.equal(pl.title, 'Mijn plaat');
+  assert.equal(pl.kind, 'album');
+  const volgorde = db.prepare("SELECT track_id FROM playlist_tracks WHERE playlist_id = 'pl1' ORDER BY position").all().map((r) => r.track_id);
+  assert.deepEqual(volgorde, ['p3', 'p1', 'p2'], 'de volgorde IS de playlist');
+});
+
+test('een playlist verwijst nooit naar een nummer dat niet aankwam', () => {
+  // Anders staat er een plaat met gaten erin die je niet kunt afspelen, en dat
+  // is weer dezelfde soort halve waarheid.
+  leeg();
+
+  track('heel', 'Heel');
+  track('stuk', 'Stuk', { padInDb: '/weg/stuk.mp3', schrijf: false });
+  db.prepare("INSERT INTO playlists (id, site_id, title) VALUES ('pl2','s1','Half')").run();
+  db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('pl2','heel',0),('pl2','stuk',1)").run();
+
+  const uit = AX.buildArchive('me');
+  leeg();
+
+  const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  const items = db.prepare("SELECT track_id FROM playlist_tracks WHERE playlist_id = 'pl2'").all().map((r) => r.track_id);
+  assert.deepEqual(items, ['heel']);
+  assert.ok(rapport.waarschuwingen.some((w) => /Half/.test(w)), 'en het wordt gemeld');
+});
+
+test('een droogloop telt de nummers maar schrijft niets', () => {
+  leeg();
+  track('d1', 'Droog');
+  const uit = AX.buildArchive('me');
+
+  leeg();
+
+  const droog = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test', dryRun: true });
+  assert.equal(droog.tracks, 1, 'het verslag hoort te zeggen hoeveel nummers er zouden komen');
+  assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0, 'maar er staat niets');
+  assert.equal(fs.readdirSync(AUDIO).length, 0, 'en er is geen bestand geschreven');
+});
+
+test.after(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* niets */ } });
Index: test/archive-export.test.js
===================================================================
--- test/archive-export.test.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ test/archive-export.test.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -134,6 +134,9 @@
 
 test('een gehoste track reist mee, met de gegevens die alleen in de database staan', () => {
-  // [[track:]] verwijst naar een audio_tracks-rij met een media-rij eronder. Op
-  // beta staan nul tracks, dus dit pad raakt daar geen echte data -- vandaar hier.
+  // Sinds v2 gaat audio NIET meer als media-bijlage mee. Dat kon ook nooit:
+  // gehoste audio staat buiten MEDIA_ROOT, de exporter rekende er met
+  // path.relative een /media/../audio/x.mp3 van, en de importer weigerde dat
+  // terecht. Resultaat op soundfabrics.nl: 13 tracks in de lijst, nul bestanden.
+  // Nu heeft het archief een eigen audio/-gebied.
   fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
   const WAV = Buffer.from('RIFF-nep-audio');
@@ -147,14 +150,87 @@
   const uit = AX.buildArchive('me');
   const o = lees(uit, 'posts/metaudio.json');
-  const a = o.attachment[0];
-  assert.equal(a['shaer:availability'], 'included');
-  assert.equal(a.mediaType, 'audio/mpeg');
-  assert.deepEqual(uit.files.get(a.url), WAV);
+  const t = o['shaer:audio'][0];
   assert.match(o.content, /\[\[track:tr1\]\]/, 'de shorthand blijft in de bron staan');
-  const t = o['shaer:audio'][0];
   assert.equal(t.name, 'Kanonnen');
   assert.equal(t.artist, 'Robin');
   assert.equal(t.license, 'CC BY');
   assert.deepEqual(t.url, ['https://open.spotify.com/track/x']);
+
+  // Dit is de assertie die er echt toe doet: de BYTES zitten in het archief.
+  assert.ok(String(t['shaer:media']).startsWith('audio/'), 'de track wijst naar het audio-gebied');
+  assert.deepEqual(uit.files.get(t['shaer:media']), WAV, 'en daar staat het geluid ook echt');
+
+  // En hij staat in de bibliotheek, los van de post.
+  const lib = JSON.parse(uit.files.get('tracks.json').toString('utf8'));
+  const rij = lib.orderedItems.find((x) => x.id === 'tr1');
+  assert.equal(rij['shaer:availability'], 'included');
+  assert.equal(rij['shaer:file'], t['shaer:media']);
+  assert.equal(uit.counts.tracks, 1);
+});
+
+test('de HELE bibliotheek gaat mee, ook wat in geen enkel bericht staat', () => {
+  // De reden dat dit bestaat: op sound-fabrics.com stonden 140 tracks en gingen
+  // er 14 mee, want alleen wat met [[track:]] in een bericht stond werd
+  // geexporteerd. Een verhuizing die je bibliotheek achterlaat is er geen.
+  fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
+  const B = Buffer.from('RIFF-los-nummer');
+  fs.writeFileSync(path.join(MEDIA, 'audio', 'los.mp3'), B);
+  db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+    .run('m9', 's1', 'los.mp3', 'audio/mpeg', B.length, path.join(MEDIA, 'audio', 'los.mp3'));
+  db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('los','s1','Los nummer','m9')").run();
+
+  const uit = AX.buildArchive('me');
+  const lib = JSON.parse(uit.files.get('tracks.json').toString('utf8'));
+  const rij = lib.orderedItems.find((x) => x.id === 'los');
+  assert.ok(rij, 'een track zonder bericht hoort er gewoon in');
+  assert.deepEqual(uit.files.get(rij['shaer:file']), B);
+});
+
+test('een track waarvan het bestand zoek is wordt gemeld, niet stil weggelaten', () => {
+  db.prepare("INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES ('mz','s1','zoek.mp3','audio/mpeg',1,'/bestaat/niet/zoek.mp3')").run();
+  db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('zoek','s1','Zoek','mz')").run();
+  const uit = AX.buildArchive('me');
+  const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems.find((x) => x.id === 'zoek');
+  assert.equal(rij['shaer:availability'], 'missing');
+  assert.equal(rij['shaer:file'], undefined, 'geen verwijzing naar een bestand dat er niet is');
+  assert.ok(uit.counts.audioMissing >= 1);
+  assert.ok(uit.missing.some((m) => m.track === 'Zoek'), 'en de beller kan het aan de gebruiker melden');
+});
+
+test('audio wordt gezocht zoals de SPELER hem zoekt, niet zoals de database hem onthoudt', async () => {
+  // De stille moordenaar op sound-fabrics.com: na een dataverhuizing wees
+  // storage_path voor 124 van de 139 tracks nog naar /srv/prutfolio/storage/audio.
+  // De speler merkte er niets van, want die pakt AUDIO_ROOT + bestandsnaam en
+  // negeert storage_path. De exporter las wel storage_path, vond niets, en liet
+  // 124 nummers weg zonder dat iemand het zag.
+  //
+  // Deze test pint de VOLGORDE vast met een nep-fs, want dat is precies waar de
+  // twee uit elkaar liepen. AUDIO_ROOT ligt bij import van paths.js al vast, dus
+  // hem hier omzetten kan niet meer; de volgorde toetsen wel.
+  const { resolveAudioPath, AUDIO_ROOT } = await import('../src/config/paths.js');
+  const gevraagd = [];
+  const nepFs = {
+    statSync(p) {
+      gevraagd.push(p);
+      if (p === path.join(AUDIO_ROOT, 'x.mp3')) return { isFile: () => true };
+      throw new Error('ENOENT');
+    },
+  };
+  const uit = resolveAudioPath('/een/heel/oud/pad/x.mp3', nepFs);
+  assert.equal(uit, path.join(AUDIO_ROOT, 'x.mp3'), 'de speler-plek wint');
+  assert.equal(gevraagd[0], path.join(AUDIO_ROOT, 'x.mp3'), 'en wordt als EERSTE geprobeerd');
+
+  // Staat hij daar niet, dan telt het opgeslagen pad alsnog: installaties die
+  // hun audio ergens anders hebben mogen niet stuk.
+  const gevraagd2 = [];
+  const nepFs2 = {
+    statSync(p) {
+      gevraagd2.push(p);
+      if (p === path.resolve('/ergens/anders/y.mp3')) return { isFile: () => true };
+      throw new Error('ENOENT');
+    },
+  };
+  assert.equal(resolveAudioPath('/ergens/anders/y.mp3', nepFs2), path.resolve('/ergens/anders/y.mp3'));
+  assert.equal(resolveAudioPath('', nepFs2), null);
 });
 
@@ -227,5 +303,5 @@
   const m = AX.buildArchive('me').manifest;
   assert.equal(m.origin, 'https://klonkt.test');
-  assert.equal(m.formatVersion, 1);
+  assert.equal(m.formatVersion, 2, 'v2 = audio zit er echt in');
   assert.equal(m.actor, 'https://klonkt.test/ap/users/me');
 });
Index: test/fep1580-migration.test.js
===================================================================
--- test/fep1580-migration.test.js	(revision fbfd7a199340cd773cd153df2388d88f2084eea2)
+++ test/fep1580-migration.test.js	(revision 6c4ff7e385f99367e75efde44e5b671992c43e88)
@@ -13,4 +13,5 @@
 import { test, beforeEach } from 'node:test';
 import assert from 'node:assert/strict';
+import path from 'node:path';
 
 process.env.DATABASE_PATH = ':memory:';
@@ -47,5 +48,5 @@
 
 beforeEach(() => {
-  for (const t of ['ap_migration', 'ap_moves', 'ap_blocks', 'posts', 'ap_followers']) {
+  for (const t of ['ap_migration', 'ap_moves', 'ap_blocks', 'posts', 'ap_followers', 'audio_tracks', 'media']) {
     try { db.prepare(`DELETE FROM ${t}`).run(); } catch { /* tabel bestaat niet in deze build */ }
   }
@@ -315,4 +316,86 @@
 });
 
+test('de muziekbibliotheek komt mee, ook wat niet fedi_open is', async () => {
+  // Losse nummers staan NIET in de outbox: die hangen aan de tracks-collectie
+  // waar de actor via AS2 `streams` naar wijst. Zonder deze tak verhuist een
+  // muzieksite zijn berichten en laat hij zijn bibliotheek achter, en dat is
+  // precies wat er op soundfabrics.nl gebeurde.
+  const s = site({ aliases: [BRON] });
+  const AUDIO = `${BRON}/audio/x.mp3`;
+  const bytes = Buffer.from('ID3-nep-geluid');
+  const kaart = new Map([
+    [BRON, {
+      id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`,
+      streams: [`${BRON}/tracks`, `${BRON}/playlists`],
+    }],
+    [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
+    [`${BRON}/tracks`, {
+      type: 'OrderedCollection',
+      orderedItems: [{
+        id: `${BRON}/tracks/t1`, type: 'Audio', name: 'Gesloten nummer', artist: 'Robo',
+        url: [{ type: 'Link', href: AUDIO, mediaType: 'audio/mpeg' }],
+      }],
+    }],
+  ]);
+  const geschreven = new Map();
+  let getekend = false;
+  const r = 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',
+      // De handtekening is hier geen detail: de audio-route van de bron weigert
+      // een kale fetch, want die kan niet zien dat wij de doel-actor zijn.
+      signHeaders: () => { getekend = true; return { Signature: 'nep' }; },
+      safeFetch: async (url, opts) => {
+        assert.equal(url, AUDIO);
+        assert.ok(opts.headers && opts.headers.Signature, 'de bytes worden ONDERTEKEND opgehaald');
+        return { ok: true, arrayBuffer: async () => bytes, headers: { get: () => 'audio/mpeg' } };
+      },
+      fs: { mkdirSync() {}, writeFileSync: (p, b) => geschreven.set(p, b) },
+      path,
+    },
+  }));
+  assert.equal(r.tracksBinnen, 1);
+  assert.equal(r.tracksMislukt, 0);
+  assert.ok(getekend);
+  assert.equal(geschreven.size, 1, 'het bestand wordt echt weggeschreven');
+  const [pad] = [...geschreven.keys()];
+  assert.ok(pad.startsWith('/nep/audio/'), 'audio hoort in AUDIO_ROOT, niet in de mediamap');
+  assert.ok(!pad.slice('/nep/audio/'.length).includes('/'), 'en er direct in, want de speler zoekt op bestandsnaam');
+  const t = db.prepare('SELECT title, artist FROM audio_tracks').get();
+  assert.equal(t.title, 'Gesloten nummer');
+});
+
+test('een nummer waarvan de bytes niet komen levert GEEN track op', async () => {
+  // Dezelfde regel als bij de zip. Een nummer dat in de lijst staat en 404't is
+  // erger dan een nummer dat ontbreekt.
+  const s = site({ aliases: [BRON] });
+  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/t1`, type: 'Audio', name: 'Weg', url: `${BRON}/audio/weg.mp3` }],
+    }],
+  ]);
+  const r = 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',
+      signHeaders: () => ({ Signature: 'nep' }),
+      safeFetch: async () => ({ ok: false, status: 403 }),
+      fs: { mkdirSync() {}, writeFileSync() { throw new Error('mag niet gebeuren'); } },
+      path,
+    },
+  }));
+  assert.equal(r.tracksBinnen, 0);
+  assert.equal(r.tracksMislukt, 1);
+  assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0);
+});
+
 test('een niet-publiek bericht komt wel mee maar niet in de publieke tabel', async () => {
   const s = site({ aliases: [BRON] });
