Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision fc664ce9ed4de0934d78a338307aaaf7de7615ac)
+++ src/services/ArchiveExportService.js	(revision f704237f2e219c1db2840feb5c33df07afa53137)
@@ -228,8 +228,14 @@
       } catch { naam = null; }           // onleesbaar telt als ontbrekend, niet als stilte
     }
-    if (!naam) {
+    // Een LINK-ONLY track is geen kapotte track. Klonkt kent dat type: geen
+    // gehost bestand, wel een Spotify- of YouTube-link, en buildNote maakt er
+    // een embed-kaart van (zie trackEmbedLinks). Die tellen dus niet als
+    // ontbrekend, anders meldt de export een probleem dat er niet is.
+    const alleenLinks = !t.media_id && [t.link_spotify, t.link_youtube, t.link_soundcloud].some(Boolean);
+    if (!naam && !alleenLinks) {
       tellingen.audioMissing += 1;
       ontbrekend.push({ track: t.title || t.id, url: t.storage_path || '(geen mediarij)' });
     }
+    if (alleenLinks) tellingen.audioLinks = (tellingen.audioLinks || 0) + 1;
     items.push({
       id: t.id, name: t.title || '', artist: t.artist || undefined, album: t.album || undefined,
@@ -247,5 +253,6 @@
       // 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',
+      // Drie staten in plaats van twee: erbij, weg, of bewust zonder bestand.
+      'shaer:availability': naam ? 'included' : (alleenLinks ? 'linkOnly' : 'missing'),
       'shaer:originalPath': naam ? undefined : (t.storage_path || undefined),
       url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision fc664ce9ed4de0934d78a338307aaaf7de7615ac)
+++ src/services/ArchiveImportService.js	(revision f704237f2e219c1db2840feb5c33df07afa53137)
@@ -166,8 +166,22 @@
     const bestand = t['shaer:file'];
     const bytes = bestand ? files.get(bestand) : null;
-    if (!bytes || !bytes.length) {
+    // "Geen bestand" en "niets om te tonen" zijn niet hetzelfde. Een LINK-ONLY
+    // track heeft nooit een bestand gehad: hij bestaat uit een Spotify- of
+    // YouTube-link en Klonkt maakt daar een embed-kaart van. Die hoort gewoon
+    // mee. Mijn eerste regel gooide hem weg, en dat kostte Robin een nummer
+    // (Youngstown) dat op de oude site prima werkte.
+    const links = Array.isArray(t.url) ? t.url.filter(Boolean) : [];
+    const alleenLinks = t['shaer:availability'] === 'linkOnly' || (!bestand && links.length > 0);
+    if ((!bytes || !bytes.length) && !alleenLinks) {
       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
+      continue;
+    }
+    if (alleenLinks) {
+      // Geen bestand om weg te schrijven, geen mediarij: alleen de track zelf.
+      werk.push({ soort: 'track', id, t, naam: null, bytes: null, doel: null, hoes: hoesTerug(files, t['shaer:coverFile'], werk) });
+      rapport.tracks += 1;
+      rapport.tracksLinks = (rapport.tracksLinks || 0) + 1;
+      continue;
     }
     // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
@@ -256,5 +270,5 @@
     posts: 0, overgeslagen: 0, overschreven: 0,
     replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
-    tracks: 0, tracksMissing: 0, playlists: 0, linksBijgetrokken: 0,
+    tracks: 0, tracksMissing: 0, tracksLinks: 0, playlists: 0, linksBijgetrokken: 0,
   };
 
@@ -417,9 +431,15 @@
         // 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);
+        //
+        // Een link-only track heeft geen bestand en dus ook geen mediarij; die
+        // krijgt media_id NULL, precies zoals op de bron.
+        let mediaId = null;
+        if (s.doel && s.bytes) {
+          fs.mkdirSync(path.dirname(s.doel), { recursive: true });
+          fs.writeFileSync(s.doel, s.bytes);
+          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
Index: src/services/MigrationService.js
===================================================================
--- src/services/MigrationService.js	(revision fc664ce9ed4de0934d78a338307aaaf7de7615ac)
+++ src/services/MigrationService.js	(revision f704237f2e219c1db2840feb5c33df07afa53137)
@@ -512,5 +512,5 @@
   const rapport = {
     bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, postsBijgewerkt: 0, media: 0, mediaMislukt: 0,
-    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, overgeslagenTracks: 0,
+    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, tracksLinks: 0, overgeslagenTracks: 0,
     playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
   };
@@ -769,6 +769,39 @@
           }
         }
-        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; }
+        // Uit de url-lijst de LINK NAAR HET BESTAND vissen, niet zomaar de eerste:
+        // buildTrackAudio zet er ook een text/html-link naar de post voor. En een
+        // LINK-ONLY track (alleen Spotify of YouTube, nooit een gehost bestand)
+        // heeft er helemaal geen. Die hoort gewoon mee, met media_id NULL.
+        // Weggooien kostte Robin een nummer dat het op de oude site prima deed.
+        const urls = [].concat(a.url || []).map((u) => (typeof u === 'string' ? { href: u } : u)).filter((u) => u && u.href);
+        const bestandLink = urls.find((u) => /^audio\//i.test(String(u.mediaType || '')))
+          || urls.find((u) => /\/audio\/stream\//.test(String(u.href)));
+        const externe = urls.map((u) => String(u.href)).filter((h) => /spotify|youtube|youtu\.be|soundcloud|bandcamp/i.test(h));
+        const bron = bestandLink && bestandLink.href;
+        if (!bron || !/^https?:\/\//i.test(String(bron))) {
+          if (externe.length) {
+            const trackIdL = ruwId(a.id) || crypto.randomUUID();
+            try {
+              db.prepare(`INSERT OR REPLACE INTO audio_tracks
+                  (id, site_id, title, artist, media_id, link_spotify, link_youtube, link_soundcloud, fedi_open)
+                VALUES (?,?,?,?,NULL,?,?,?,0)`)
+                .run(trackIdL, site.id, a.name || 'zonder titel', a.summary || a.artist || null,
+                  externe.find((u) => /spotify/i.test(u)) || null,
+                  externe.find((u) => /youtube|youtu\.be/i.test(u)) || null,
+                  externe.find((u) => /soundcloud/i.test(u)) || null);
+              recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackIdL}`, sourceActor: bronActor.id, isPublic: false });
+              trackKaart.set(String(a.id), trackIdL);
+              ruwKaart.set(ruwId(a.id), trackIdL);
+              rapport.tracksLinks++;
+              rapport.tracksBinnen++;
+            } catch (e) {
+              rapport.tracksMislukt++;
+              rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
+            }
+            continue;
+          }
+          rapport.tracksMislukt++;
+          continue;
+        }
         const g = await haalBijlage(String(bron), {
           safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
Index: test/archive-audio.test.js
===================================================================
--- test/archive-audio.test.js	(revision fc664ce9ed4de0934d78a338307aaaf7de7615ac)
+++ test/archive-audio.test.js	(revision f704237f2e219c1db2840feb5c33df07afa53137)
@@ -258,3 +258,45 @@
 });
 
+test('een track met alleen Spotify/YouTube-links reist gewoon mee', () => {
+  // Robins Youngstown-nummer. Klonkt kent link-only tracks: geen gehost
+  // bestand, wel externe links, en buildNote maakt daar een embed-kaart van.
+  // Mijn regel "geen bestand, geen track" gooide die weg, en dat was te grof:
+  // "geen bestand" en "niets om te tonen" zijn niet hetzelfde.
+  leeg();
+  db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, media_id, link_spotify, link_youtube)
+              VALUES ('lo','s1','Alleen links','Youngstown',NULL,
+                      'https://open.spotify.com/track/abc','https://www.youtube.com/watch?v=xyz')`).run();
+
+  const uit = AX.buildArchive('me');
+  const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems.find((x) => x.id === 'lo');
+  assert.equal(rij['shaer:availability'], 'linkOnly', 'een eigen staat, niet "missing"');
+  assert.equal(uit.counts.audioMissing, 0, 'en hij telt niet als ontbrekend: er mist niets');
+  assert.deepEqual(rij.url, ['https://open.spotify.com/track/abc', 'https://www.youtube.com/watch?v=xyz']);
+
+  leeg();
+  const r = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  assert.equal(r.tracks, 1);
+  assert.equal(r.tracksMissing, 0);
+  const t = db.prepare("SELECT title, artist, media_id, link_spotify, link_youtube FROM audio_tracks WHERE id = 'lo'").get();
+  assert.ok(t, 'de track hoort er te staan');
+  assert.equal(t.media_id, null, 'zonder mediarij, precies zoals op de bron');
+  assert.equal(t.title, 'Alleen links');
+  assert.equal(t.link_spotify, 'https://open.spotify.com/track/abc');
+  assert.equal(t.link_youtube, 'https://www.youtube.com/watch?v=xyz');
+});
+
+test('een track zonder bestand EN zonder links blijft wel geweigerd', () => {
+  // De oorspronkelijke regel blijft staan waar hij hoort: dit is een track die
+  // niets kan tonen en niets kan afspelen.
+  leeg();
+  track('kapot', 'Kapot', { padInDb: '/weg/kapot.mp3', schrijf: false });
+  const uit = AX.buildArchive('me');
+  assert.equal(uit.counts.audioMissing, 1);
+  leeg();
+  const r = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
+  assert.equal(r.tracks, 0);
+  assert.equal(r.tracksMissing, 1);
+  assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0);
+});
+
 test.after(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* niets */ } });
Index: test/fep1580-migration.test.js
===================================================================
--- test/fep1580-migration.test.js	(revision fc664ce9ed4de0934d78a338307aaaf7de7615ac)
+++ test/fep1580-migration.test.js	(revision f704237f2e219c1db2840feb5c33df07afa53137)
@@ -369,4 +369,76 @@
 });
 
+test('een link-only nummer komt ook via de ophaalknop mee', async () => {
+  // Dezelfde regel als bij de zip. Over AP heeft zo'n track geen audio-link in
+  // zijn url-lijst, alleen de externe. De vorige versie viste blind de eerste
+  // url en strandde daarop.
+  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', totalItems: 0, first: `${BRON}/outbox?page=1` }],
+    [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [] }],
+    [`${BRON}/tracks`, { type: 'OrderedCollection', orderedItems: [{
+      id: `${BRON}/tracks/lo`, type: 'Audio', name: 'Alleen links', summary: 'Youngstown',
+      url: [
+        { type: 'Link', href: 'https://oud.example/eenpost', mediaType: 'text/html' },
+        { type: 'Link', href: 'https://open.spotify.com/track/abc' },
+        { type: 'Link', href: 'https://www.youtube.com/watch?v=xyz' },
+      ],
+    }] }],
+  ]);
+  const gehaald = [];
+  const r = await stil(() => Mig.ingestFromSource(s, { deps: {
+    getJson: async (_s, url) => kaart.get(url) || null,
+    noteId: (b, id) => `${b}/ap/notes/${id}`,
+    noteVisibility: AP.noteVisibility,
+    audioRoot: '/nep/audio', mediaRoot: '/nep/media',
+    signHeaders: () => ({ Signature: 'nep' }),
+    safeFetch: async (u) => { gehaald.push(u); return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } }; },
+    fs: { mkdirSync() {}, writeFileSync() {}, statSync() { throw new Error('ENOENT'); } },
+    path,
+  } }));
+  assert.equal(r.tracksBinnen, 1);
+  assert.equal(r.tracksMislukt, 0);
+  assert.equal(r.tracksLinks, 1);
+  assert.deepEqual(gehaald, [], 'er valt niets te downloaden, en dat hoort ook niet geprobeerd');
+  const t = db.prepare("SELECT title, artist, media_id, link_spotify, link_youtube FROM audio_tracks WHERE id = 'lo'").get();
+  assert.ok(t, 'de track staat er');
+  assert.equal(t.media_id, null);
+  assert.equal(t.artist, 'Youngstown', 'de artiest komt uit summary');
+  assert.equal(t.link_spotify, 'https://open.spotify.com/track/abc');
+  assert.equal(t.link_youtube, 'https://www.youtube.com/watch?v=xyz');
+});
+
+test('de audio-link wordt uit de lijst GEVIST, niet blind de eerste gepakt', async () => {
+  // buildTrackAudio zet een text/html-link naar de post VOOR het bestand. Wie
+  // element nul pakt downloadt een HTML-pagina en noemt dat een mp3.
+  const s = site({ aliases: [BRON] });
+  const MP3 = 'https://oud.example/audio/stream/x.mp3';
+  const kaart = new Map([
+    [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
+    [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 0, first: `${BRON}/outbox?page=1` }],
+    [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [] }],
+    [`${BRON}/tracks`, { type: 'OrderedCollection', orderedItems: [{
+      id: `${BRON}/tracks/m1`, type: 'Audio', name: 'Met bestand',
+      url: [
+        { type: 'Link', href: 'https://oud.example/depost', mediaType: 'text/html' },
+        { type: 'Link', href: MP3, mediaType: 'audio/mpeg' },
+      ],
+    }] }],
+  ]);
+  const gehaald = [];
+  await stil(() => Mig.ingestFromSource(s, { deps: {
+    getJson: async (_s, url) => kaart.get(url) || null,
+    noteId: (b, id) => `${b}/ap/notes/${id}`,
+    noteVisibility: AP.noteVisibility,
+    audioRoot: '/nep/audio', mediaRoot: '/nep/media',
+    signHeaders: () => ({ Signature: 'nep' }),
+    safeFetch: async (u) => { gehaald.push(u); return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } }; },
+    fs: { mkdirSync() {}, writeFileSync() {}, statSync() { throw new Error('ENOENT'); } },
+    path,
+  } }));
+  assert.deepEqual(gehaald, [MP3], 'het bestand, niet de postpagina');
+});
+
 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
