Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 026173008d0d963628475bb6c9caa53d040cea7c)
+++ src/services/ActivityPubService.js	(revision abcf51a249d35a16ca5d931a1bfd08dc6499fd5d)
@@ -967,6 +967,6 @@
  * ophaalt niet denkt dat er iets nieuws is.
  */
-export function buildTrackCreate(base, site, r) {
-  const audio = buildTrackAudio(base, site, r);
+export function buildTrackCreate(base, site, r, opts = {}) {
+  const audio = buildTrackAudio(base, site, r, opts);
   const me = actorId(base, site.slug);
   return {
@@ -1006,5 +1006,9 @@
   const items = [
     ...(posts || []).map((p) => buildCreate(base, site, p)),
-    ...(tracks || []).map((r) => buildTrackCreate(base, site, r)),
+    // Eén zoekopdracht voor alle tracks samen, niet per stuk.
+    ...(() => {
+      const posts = (tracks || []).length && site.id ? trackHostPosts(site.id) : null;
+      return (tracks || []).map((r) => buildTrackCreate(base, site, r, { hostPosts: posts }));
+    })(),
   ]
     .sort((a, b) => wanneer(b) - wanneer(a))
@@ -1130,4 +1134,50 @@
  * belooft.
  */
+/**
+ * Bij welke post hoort een track? (shaer-0nh)
+ *
+ * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
+ * een van drie insluitingen in posts.content. Die relatie stond alleen in die
+ * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
+ * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
+ *
+ * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
+ * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
+ * scans. Nu is het er een, en de map gaat mee als optie.
+ *
+ * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
+ * is specifieker dan via een playlist, en die weer specifieker dan via een
+ * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
+ * laatst heeft uitgebracht.
+ */
+export function trackHostPosts(siteId) {
+  const rijen = db.prepare(`
+    SELECT tid, post_id, post_slug, rang, wanneer FROM (
+      SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
+             COALESCE(p.published_at, p.created_at) AS wanneer
+        FROM audio_tracks t
+        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
+                    AND p.content LIKE '%[[track:' || t.id || ']]%'
+       WHERE t.site_id = ? AND t.fedi_open = 1
+      UNION ALL
+      SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
+        FROM playlist_tracks pt
+        JOIN audio_tracks t ON t.id = pt.track_id
+        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
+                    AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
+       WHERE t.site_id = ? AND t.fedi_open = 1
+      UNION ALL
+      SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
+        FROM audio_tracks t
+        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
+                    AND p.content LIKE '%[[album:' || t.album || ']]%'
+       WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
+    ) ORDER BY rang, wanneer DESC
+  `).all(siteId, siteId, siteId);
+  const uit = new Map();
+  for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
+  return uit;
+}
+
 export function buildTrackAudio(base, site, r, opts = {}) {
   const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
@@ -1136,4 +1186,11 @@
   // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
   // Zo doet Funkwhale het ook.
+  // De post waar dit nummer in staat. Meegegeven door de collectie (een
+  // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
+  // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
+  const post = opts.hostPosts !== undefined
+    ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
+    : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
+
   const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
   if (Number(r.size)) bestand.size = Number(r.size);
@@ -1152,7 +1209,17 @@
     // track moet zelf kunnen zeggen dat hij openbaar is.
     to: [PUBLIC],
-    url: [bestand],
+    // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
+    // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
+    // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
+    // horen, en dat is wat zo'n link betekent.
+    url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
   };
   if (r.artist) a.summary = r.artist;              // artiest als summary: kaal AS2, geen eigen vocab
+  // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
+  // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
+  // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
+  // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
+  // van te maken.
+  if (post) a.context = noteId(base, post.id);
   if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
   if (r.created_at) a.published = new Date(r.created_at).toISOString();
@@ -1201,5 +1268,9 @@
     attributedTo: actorId(base, site.slug),
     totalItems: (rows || []).length,
-    orderedItems: (rows || []).map((r) => buildTrackAudio(base, site, r)),
+    // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
+    orderedItems: (() => {
+      const posts = site.id ? trackHostPosts(site.id) : null;
+      return (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
+    })(),
   };
 }
@@ -1263,5 +1334,6 @@
   // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
   // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null }));
+  const hostPosts = site.id ? trackHostPosts(site.id) : null;
+  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
   const out = {
     '@context': AP_CONTEXT,
@@ -6189,5 +6261,5 @@
   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
   channelUrls, channelCategory, timelineFields, guessMediaType,
-  siteOpenTracks, openTrack, buildTrackAudio, buildTrackCollection, buildTrackCreate,
+  siteOpenTracks, openTrack, buildTrackAudio, buildTrackCollection, buildTrackCreate, trackHostPosts,
   buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
Index: test/activitypub-as2.test.js
===================================================================
--- test/activitypub-as2.test.js	(revision 026173008d0d963628475bb6c9caa53d040cea7c)
+++ test/activitypub-as2.test.js	(revision abcf51a249d35a16ca5d931a1bfd08dc6499fd5d)
@@ -26,4 +26,7 @@
   'content', 'name', 'summary', 'url', 'href', 'mediaType',
   'published', 'updated', 'attributedTo', 'inReplyTo', 'replies',
+  // AS2-kern: de context waarbinnen een object bestaat. Een track wijst ermee
+  // naar de post die hem uitbrengt (shaer-0nh).
+  'context',
   'attachment', 'tag', 'icon', 'image', 'duration',
   'contentMap', 'nameMap', 'summaryMap', // AS2 @language-map counterparts of content/name/summary
Index: test/ap-track-context.test.js
===================================================================
--- test/ap-track-context.test.js	(revision abcf51a249d35a16ca5d931a1bfd08dc6499fd5d)
+++ test/ap-track-context.test.js	(revision abcf51a249d35a16ca5d931a1bfd08dc6499fd5d)
@@ -0,0 +1,104 @@
+// Een track wijst naar de post die hem uitbrengt (shaer-0nh).
+//
+// Aanleiding: sinds Create(Audio) in de outbox staat (fb22f78) bouwt Shaer zijn
+// HomeBase-feed uit diezelfde outbox, en maakte het van elke Audio een lege
+// kaart -- een Audio heeft geen `content` en onze `url` is een Link-array.
+//
+// De relatie stond alleen in posts.content ([[track:]], [[playlist:]],
+// [[album:]]) en nergens op de draad. Nu wel: AS2-kern `context` wijst naar de
+// Note van de post, en de text/html-link vooraan wijst naar de pagina. Een
+// lezer die de post al heeft kan het nummer daarmee overslaan in plaats van er
+// een dode kaart van te maken.
+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', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public, is_primary) VALUES (?,?,?,?,1,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, duration, media_id, album, fedi_open) VALUES (?,?,?,?,?,?,1)');
+for (const [id, titel, album] of [['t-los', 'Losse track', null], ['t-pl', 'Via playlist', null],
+  ['t-alb', 'Via album', 'De Plaat'], ['t-wees', 'Nergens ingesloten', null]]) {
+  insM.run('m-' + id, 's1', id + '.mp3', 'audio/' + id + '.mp3', 'audio/mpeg');
+  insT.run(id, 's1', titel, 60, 'm-' + id, album);
+}
+db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('plaat','s1','De Plaat','album')").run();
+db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('plaat','t-pl',1)").run();
+
+const insP = db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
+                         VALUES (?,?,?,?,?,?,'published',?)`);
+insP.run('p-los', 's1', 'u1', 'losse-post', 'Losse post', 'Hier: [[track:t-los]]', '2026-01-01T00:00:00Z');
+insP.run('p-pl', 's1', 'u1', 'playlist-post', 'Playlist-post', 'Nieuw album [[playlist:plaat]]', '2026-02-01T00:00:00Z');
+insP.run('p-alb', 's1', 'u1', 'album-post', 'Album-post', 'Luister [[album:De Plaat]]', '2026-03-01T00:00:00Z');
+
+const site = () => db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+const audioVoor = (id) => {
+  const r = AP.siteOpenTracks('s1').find((x) => x.id === id);
+  return AP.buildTrackAudio(BASE, site(), r);
+};
+
+test('rechtstreeks ingesloten: context wijst naar de Note van die post', () => {
+  const a = audioVoor('t-los');
+  assert.equal(a.context, 'https://test.example/ap/notes/p-los');
+  assert.deepEqual(a.url[0], { type: 'Link', href: 'https://test.example/losse-post', mediaType: 'text/html' },
+    'de post staat VOORAAN, zoals Funkwhale zijn trackpagina zet');
+  assert.equal(a.url[1].mediaType, 'audio/mpeg', 'het bestand komt daarna');
+});
+
+test('via een playlist gevonden', () => {
+  assert.equal(audioVoor('t-pl').context, 'https://test.example/ap/notes/p-pl');
+});
+
+test('via een albumnaam gevonden', () => {
+  assert.equal(audioVoor('t-alb').context, 'https://test.example/ap/notes/p-alb');
+});
+
+test('een track die nergens is ingesloten krijgt GEEN context', () => {
+  const a = audioVoor('t-wees');
+  assert.equal(a.context, undefined, 'niets verzinnen als er geen post is');
+  assert.equal(a.url.length, 1, 'dan ook geen text/html-link');
+  assert.equal(a.url[0].mediaType, 'audio/mpeg');
+});
+
+test('rechtstreeks wint van playlist wint van album', () => {
+  // t-pl zit in playlist "plaat"; die playlist is ook een album met dezelfde
+  // naam. Sluit een NIEUWERE post hem rechtstreeks in, dan wint die -- de
+  // specifiekste insluiting, niet de laatste.
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
+              VALUES ('p-direct','s1','u1','direct-post','Direct','Deze: [[track:t-pl]]','published','2025-01-01T00:00:00Z')`).run();
+  assert.equal(audioVoor('t-pl').context, 'https://test.example/ap/notes/p-direct',
+    'ouder maar specifieker gaat voor');
+  db.prepare("DELETE FROM posts WHERE id = 'p-direct'").run();
+});
+
+test('een concept telt niet als host', () => {
+  db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status)
+              VALUES ('p-concept','s1','u1','concept','Concept','[[track:t-wees]]','draft')`).run();
+  assert.equal(audioVoor('t-wees').context, undefined, 'alleen gepubliceerde posts hosten iets');
+  db.prepare("DELETE FROM posts WHERE id = 'p-concept'").run();
+});
+
+test('de collectie doet EEN zoekopdracht en levert dezelfde koppelingen', () => {
+  const col = AP.buildTrackCollection(BASE, site(), AP.siteOpenTracks('s1'));
+  const perNaam = Object.fromEntries(col.orderedItems.map((a) => [a.name, a.context]));
+  assert.equal(perNaam['Losse track'], 'https://test.example/ap/notes/p-los');
+  assert.equal(perNaam['Via playlist'], 'https://test.example/ap/notes/p-pl');
+  assert.equal(perNaam['Nergens ingesloten'], undefined);
+});
+
+test('en de outbox draagt de context ook', () => {
+  const ob = AP.buildOutbox(BASE, site(), [], AP.siteOpenTracks('s1'));
+  const c = ob.orderedItems.find((x) => (x.object || {}).name === 'Losse track');
+  assert.equal(c.object.context, 'https://test.example/ap/notes/p-los');
+});
