Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 6259305d61295ab18fffdd75204e61e27301ad75)
+++ src/services/ActivityPubService.js	(revision e62eee4b3ba1595f02ee56042ffbb72b7e0fb115)
@@ -595,8 +595,36 @@
   // a click-through to the protected player (discovery without leaking the file).
   const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
+  // Elke titel met zijn track-id erbij, zodat hij hieronder een EIGEN link
+  // krijgt naar #track-<id> op de postpagina (shaer-38y). Zonder id was dit een
+  // vetgedrukte opsomming waar je niets mee kon: vijf namen en een enkele
+  // "listen on"-link naar de post als geheel. Elke track heeft daar al een
+  // anker -- direct ingesloten, in een album of in een playlist -- dus dit
+  // wijst naar precies het nummer waar de naam bij hoort.
   const audioLabels = [];
   try {
-    for (const m of body.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare('SELECT title FROM audio_tracks WHERE id = ?').get(m[1]); if (r && r.title) audioLabels.push(r.title); }
-    for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
+    const zien = new Set();
+    const voegToe = (id, titel) => {
+      const t = String(titel || '').trim();
+      if (!t) return;
+      const sleutel = id || ('naam:' + t);
+      if (zien.has(sleutel)) return;
+      zien.add(sleutel);
+      audioLabels.push({ id: id || null, titel: t });
+    };
+    // In de volgorde van de POST: een enkele scan over alle drie de vormen,
+    // zodat de opsomming leest zoals de post is neergezet.
+    for (const m of body.matchAll(/\[\[(track|album|playlist):([^\]]+)\]\]/gi)) {
+      const soort = m[1].toLowerCase(), waarde = m[2].trim();
+      if (soort === 'track') {
+        const r = db.prepare('SELECT id, title FROM audio_tracks WHERE id = ?').get(waarde);
+        if (r) voegToe(r.id, r.title);
+      } else if (soort === 'album') {
+        const rs = db.prepare('SELECT id, title FROM audio_tracks WHERE site_id = ? AND album = ? ORDER BY rowid').all(site.id, waarde);
+        if (rs.length) for (const r of rs) voegToe(r.id, r.title);
+        else voegToe(null, waarde);            // album zonder tracks: dan maar de naam
+      } else {
+        for (const r of db.prepare('SELECT t.id, t.title FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id WHERE pt.playlist_id = ? ORDER BY pt.position').all(waarde)) voegToe(r.id, r.title);
+      }
+    }
   } catch { /* non-fatal */ }
   // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
@@ -633,5 +661,9 @@
   });
   if (hadAudio) {
-    const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : '';
+    // Elke titel als eigen link naar zijn anker; een titel zonder id (een
+    // albumnaam zonder tracks) blijft gewone tekst.
+    const lbl = audioLabels.slice(0, 4)
+      .map((a) => (a.id ? `<a href="${human}#track-${esc(a.id)}">${esc(a.titel)}</a>` : esc(a.titel)))
+      .join(', ');
     if (trackEmbedLinks.length) {
       // Link-only track(s): emit the external link(s). Mastodon cards the first (Spotify → its
Index: test/ap-note-track-links.test.js
===================================================================
--- test/ap-note-track-links.test.js	(revision e62eee4b3ba1595f02ee56042ffbb72b7e0fb115)
+++ test/ap-note-track-links.test.js	(revision e62eee4b3ba1595f02ee56042ffbb72b7e0fb115)
@@ -0,0 +1,78 @@
+// De chips in het lijf van een Note krijgen een eigen URL (shaer-38y).
+//
+// Eerder was dit een vetgedrukte opsomming van titels met een enkele "listen
+// on"-link naar de post als geheel: vijf namen waar een lezer niets mee kon.
+// Elke track heeft op de postpagina al een anker (#track-<id>) -- direct
+// ingesloten, in een album of in een playlist -- dus elke naam kan naar precies
+// het nummer wijzen waar hij bij hoort.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+const BASE = 'https://test.example';
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'robin', 'r@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public) VALUES (?,?,?,?,1)')
+  .run('s1', 'band', 'De Band', 'u1');
+
+const insM = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,1000)');
+const insT = db.prepare('INSERT INTO audio_tracks (id, site_id, title, album, duration, media_id, fedi_open) VALUES (?,?,?,?,?,?,?)');
+insM.run('m1', 's1', 'een.mp3', 'audio/een.mp3', 'audio/mpeg');
+insM.run('m2', 's1', 'twee.mp3', 'audio/twee.mp3', 'audio/mpeg');
+insM.run('m3', 's1', 'drie.mp3', 'audio/drie.mp3', 'audio/mpeg');
+insT.run('t1', 's1', 'Eerste', 'De Plaat', 200, 'm1', 1);
+insT.run('t2', 's1', 'Tweede', 'De Plaat', 180, 'm2', 1);
+insT.run('t3', 's1', 'Derde', null, 160, 'm3', 1);
+
+db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('lijst','s1','De Lijst','playlist')").run();
+db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('lijst','t3',0)").run();
+
+const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+const note = (content, id = 'p1') =>
+  AP.buildNote(BASE, site, { id, slug: id, title: 'T', content, status: 'published' });
+
+test('een losse track wordt een link naar zijn eigen anker', () => {
+  const c = note('[[track:t1]]').content;
+  assert.ok(c.includes(`<a href="${BASE}/p1#track-t1">Eerste</a>`), c.slice(-300));
+});
+
+test('meerdere tracks: elk zijn eigen link, in de volgorde van de post', () => {
+  const c = note('[[track:t2]] en [[track:t1]]', 'p2').content;
+  const namen = [...c.matchAll(/#track-(t\d)">([^<]+)</g)].map((m) => [m[1], m[2]]);
+  assert.deepEqual(namen, [['t2', 'Tweede'], ['t1', 'Eerste']]);
+});
+
+test('een album-insluiting levert links per track, niet een albumnaam', () => {
+  const c = note('[[album:De Plaat]]', 'p3').content;
+  assert.ok(c.includes(`#track-t1">Eerste</a>`));
+  assert.ok(c.includes(`#track-t2">Tweede</a>`));
+});
+
+test('een playlist ook -- die gaf eerder helemaal geen namen', () => {
+  const c = note('[[playlist:lijst]]', 'p4').content;
+  assert.ok(c.includes(`#track-t3">Derde</a>`), 'de tracks van de lijst staan er nu bij');
+});
+
+test('een albumnaam zonder tracks blijft gewone tekst', () => {
+  const c = note('[[album:Bestaat Niet]]', 'p5').content;
+  assert.ok(c.includes('Bestaat Niet'));
+  assert.ok(!/#track-[^"]*">Bestaat Niet/.test(c), 'geen link naar een anker dat er niet is');
+});
+
+test('dezelfde track twee keer levert een link, niet twee', () => {
+  const c = note('[[track:t1]] en nog eens [[track:t1]]', 'p6').content;
+  assert.equal([...c.matchAll(/#track-t1"/g)].length, 1);
+});
+
+test('en de listen-link naar de post zelf blijft staan', () => {
+  const c = note('[[track:t1]]', 'p7').content;
+  assert.ok(/listen on/.test(c), 'de uitnodiging naar de pagina als geheel verdwijnt niet');
+});
