Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision bdcb3a3bace9358b794b21d3bbb1462807f22d06)
+++ src/routes/activitypub.js	(revision 39a9d21fd2b2e8b7263e26275d94ba5fe52fedf8)
@@ -676,4 +676,24 @@
 });
 
+// De tracks van deze site: de kanonieke plek voor onze muziek (shaer-0nh,
+// 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) => {
+  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)));
+});
+
+// Eén track, los op te halen. Een gesloten track is AFWEZIG, niet leeg: 404,
+// dezelfde regel als in de collectie, zodat het bestaan van een gated nummer
+// niet uit een ander antwoord af te leiden is.
+router.get('/ap/users/:slug/tracks/:id', (req, res) => {
+  const site = publicSite(req.params.slug);
+  if (!site) return res.status(404).end();
+  const row = AP.openTrack(site.id, req.params.id);
+  if (!row) return res.status(404).end();
+  AP.sendAP(res, AP.buildTrackAudio(baseUrl(req), site, row, { standalone: true }));
+});
+
 router.get('/ap/users/:slug/playlists/:id', (req, res) => {
   const site = publicSite(req.params.slug);
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision bdcb3a3bace9358b794b21d3bbb1462807f22d06)
+++ src/services/ActivityPubService.js	(revision 39a9d21fd2b2e8b7263e26275d94ba5fe52fedf8)
@@ -179,4 +179,20 @@
 // ── document builders ─────────────────────────────────────────────
 export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
+
+/**
+ * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
+ * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
+ * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
+ * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
+ * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
+ */
+export function guessMediaType(u) {
+  const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
+  return ({
+    jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+    webp: 'image/webp', avif: 'image/avif',
+    mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
+  })[(e || '').toLowerCase()] || 'image/jpeg';
+}
 export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
 
@@ -311,5 +327,5 @@
     // interest" -- precies wat de playlist-lijst is (shaer-ayc, stap 2).
     // Geen eigen vocabulaire nodig, en wie het niet kent negeert het.
-    streams: [`${id}/playlists`],
+    streams: [`${id}/tracks`, `${id}/playlists`],
     // AP §5.6: the private blocked collection (owner-only GET). The server
     // list is the source of truth for Shaer's "in Orbit"; clients keep no
@@ -483,8 +499,4 @@
   // to avoid duplicate rendering on clients that DO keep them.
   const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const mediaType = (u) => {
-    const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
-    return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' })[(e || '').toLowerCase()] || 'image/jpeg';
-  };
   const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
   const playable = hasPlayableAudio(post.content || '', site && site.id);
@@ -580,5 +592,5 @@
       // Mastodon renders it as the artwork thumbnail on its native audio player.
       const art = abs(r.cover_url || post.cover_image_url || null);
-      if (art) a.icon = { type: 'Image', mediaType: mediaType(art), url: art };
+      if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
       openAudio.push(a);
     };
@@ -635,5 +647,5 @@
   const attachment = urls.filter((x) => x && x.url)
     .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
-    .map((x) => { const mt = x.mt || mediaType(x.url); // the stored type wins; the extension map is the fallback
+    .map((x) => { const mt = x.mt || guessMediaType(x.url); // the stored type wins; the extension map is the fallback
       const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
       const a = { type: ty, mediaType: mt, url: x.url };
@@ -684,5 +696,5 @@
   if (post.cover_image_url && noImages) {
     const cov = abs(post.cover_image_url);
-    if (cov) { note.image = { type: 'Image', mediaType: mediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
+    if (cov) { note.image = { type: 'Image', mediaType: guessMediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
   }
   // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
@@ -1000,7 +1012,10 @@
 // open deel: een eerlijke telling over wat er werkelijk in de collectie staat,
 // niet over wat wij thuis in de kast hebben.
+const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
+     m.filename, m.storage_path, m.mime_type`;
+
 export function playlistOpenTracks(playlistId) {
   return db.prepare(
-    `SELECT t.title, t.artist, t.duration, t.cover_url, m.filename, m.storage_path, m.mime_type
+    `SELECT ${TRACK_KOLOMMEN}
      FROM playlist_tracks pt
      JOIN audio_tracks t ON t.id = pt.track_id
@@ -1009,4 +1024,75 @@
      ORDER BY pt.position`
   ).all(playlistId);
+}
+
+/**
+ * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
+ *
+ * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
+ * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
+ * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
+ */
+export function siteOpenTracks(siteId) {
+  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
+     ORDER BY t.position, t.created_at, t.id`
+  ).all(siteId);
+}
+
+export function openTrack(siteId, trackId) {
+  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.id = ? AND t.fedi_open = 1`
+  ).get(siteId, trackId);
+}
+
+/**
+ * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
+ *
+ * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
+ * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
+ * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
+ * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
+ * precies zo, per stuk, in Create en Delete.
+ *
+ * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
+ * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
+ * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
+ * track wordt getoond binnen een post, en een post over vijf nummers is niet de
+ * pagina van dit ene nummer. Liever geen link dan een link die iets anders
+ * belooft.
+ */
+export function buildTrackAudio(base, site, r, opts = {}) {
+  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
+  const fn = r.filename || (r.storage_path || '').split('/').pop();
+  const a = {
+    ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
+    id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
+    type: 'Audio',
+    name: r.title || 'Audio',
+    attributedTo: actorId(base, site.slug),
+    url: [{ type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' }],
+  };
+  if (r.artist) a.summary = r.artist;              // artiest als summary: kaal AS2, geen eigen vocab
+  if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
+  if (r.created_at) a.published = new Date(r.created_at).toISOString();
+  const art = abs(r.cover_url || opts.coverFallback || null);
+  if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
+  return a;
+}
+
+/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
+export function buildTrackCollection(base, site, rows) {
+  return {
+    '@context': AP_CONTEXT,
+    id: `${actorId(base, site.slug)}/tracks`,
+    type: 'OrderedCollection',
+    attributedTo: actorId(base, site.slug),
+    totalItems: (rows || []).length,
+    orderedItems: (rows || []).map((r) => buildTrackAudio(base, site, r)),
+  };
 }
 
@@ -1064,23 +1150,10 @@
 export function buildPlaylistCollection(base, site, playlist, rows) {
   const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  // Zelfde afleiding als in buildNote; die daar is functie-lokaal.
-  const mediaType = (u) => {
-    const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
-    return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' })[(e || '').toLowerCase()] || 'image/jpeg';
-  };
-  const items = (rows || []).map((r) => {
-    const fn = r.filename || (r.storage_path || '').split('/').pop();
-    const a = {
-      type: 'Audio',
-      mediaType: r.mime_type || 'audio/mpeg',
-      url: `${base}/audio/stream/${encodeURIComponent(fn)}`,
-      name: r.title || 'Audio',
-    };
-    if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
-    if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
-    const art = abs(r.cover_url || playlist.cover_url || null);
-    if (art) a.icon = { type: 'Image', mediaType: mediaType(art), url: art };
-    return a;
-  });
+  // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
+  // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
+  // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
+  // 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 out = {
     '@context': AP_CONTEXT,
@@ -1098,5 +1171,5 @@
   if (parts.length) out.summary = parts.join(' · ');
   const cover = abs(playlist.cover_url || null);
-  if (cover) out.icon = { type: 'Image', mediaType: mediaType(cover), url: cover };
+  if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
   return out;
 }
@@ -5895,5 +5968,6 @@
   AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions,
   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
-  channelUrls, timelineFields,
+  channelUrls, timelineFields, guessMediaType,
+  siteOpenTracks, openTrack, buildTrackAudio, buildTrackCollection,
   buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
Index: test/ap-playlist-discovery.test.js
===================================================================
--- test/ap-playlist-discovery.test.js	(revision bdcb3a3bace9358b794b21d3bbb1462807f22d06)
+++ test/ap-playlist-discovery.test.js	(revision 39a9d21fd2b2e8b7263e26275d94ba5fe52fedf8)
@@ -55,5 +55,10 @@
 test('de actor wijst via streams naar de playlist-lijst', async () => {
   const { body } = await getJson('/ap/users/band');
-  assert.deepEqual(body.streams, ['https://test.example/ap/users/band/playlists']);
+  // Sinds stap 3 staat de TRACK-collectie er ook in, en vooraan: die is de
+  // kanonieke plek voor onze muziek, de playlists zijn een keuze daaruit.
+  assert.deepEqual(body.streams, [
+    'https://test.example/ap/users/band/tracks',
+    'https://test.example/ap/users/band/playlists',
+  ]);
 });
 
Index: test/ap-playlist.test.js
===================================================================
--- test/ap-playlist.test.js	(revision bdcb3a3bace9358b794b21d3bbb1462807f22d06)
+++ test/ap-playlist.test.js	(revision 39a9d21fd2b2e8b7263e26275d94ba5fe52fedf8)
@@ -79,6 +79,10 @@
   for (const a of body.orderedItems) {
     assert.equal(a.type, 'Audio');
-    assert.match(a.url, /^https:\/\/test\.example\/audio\/stream\//);
-    assert.equal(a.mediaType, 'audio/mpeg');
+    // Sinds stap 3: een eigen id, en url als Link-array -- de mediaType hoort
+    // bij de link, niet bij het object.
+    assert.match(a.id, /^https:\/\/test\.example\/ap\/users\/band\/tracks\//);
+    assert.ok(Array.isArray(a.url), 'url is een Link-array');
+    assert.match(a.url[0].href, /^https:\/\/test\.example\/audio\/stream\//);
+    assert.equal(a.url[0].mediaType, 'audio/mpeg');
   }
   assert.equal(body.orderedItems[0].duration, 'PT215S');
Index: test/ap-tracks.test.js
===================================================================
--- test/ap-tracks.test.js	(revision 39a9d21fd2b2e8b7263e26275d94ba5fe52fedf8)
+++ test/ap-tracks.test.js	(revision 39a9d21fd2b2e8b7263e26275d94ba5fe52fedf8)
@@ -0,0 +1,113 @@
+// Onze tracks als eersterangs Audio-objecten (shaer-0nh, stap 3).
+//
+// De actor-collectie is de kanonieke plek: een playlist is een KEUZE daaruit.
+// Een track die in geen playlist staat hoort er dus wel in, en een track die in
+// twee playlists staat is twee keer HETZELFDE ding -- zelfde id.
+//
+// De poortregel blijft: een gesloten track is afwezig, niet leeg.
+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 express = (await import('express')).default;
+const routes = (await import('../src/routes/activitypub.js')).default;
+
+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 (?,?,?,?,?,1)');
+const insT = db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, cover_url, fedi_open, position) VALUES (?,?,?,?,?,?,?,?,?)');
+insM.run('m1', 's1', 'open.mp3', 'audio/open.mp3', 'audio/mpeg');
+insM.run('m2', 's1', 'dicht.mp3', 'audio/dicht.mp3', 'audio/mpeg');
+insM.run('m3', 's1', 'los.mp3', 'audio/los.mp3', 'audio/mpeg');
+insT.run('t-open', 's1', 'Open nummer', 'De Band', 203, 'm1', '/media/hoes.jpg', 1, 1);
+insT.run('t-dicht', 's1', 'Gesloten nummer', 'De Band', 100, 'm2', null, 0, 2);
+insT.run('t-los', 's1', 'Zonder playlist', 'De Band', 90, 'm3', null, 1, 3);
+
+db.prepare("INSERT INTO playlists (id, site_id, title, kind, cover_url) VALUES ('plaat','s1','De Plaat','album','/media/plaathoes.jpg')").run();
+db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('mix','s1','De Mix','playlist')").run();
+const insPT = db.prepare('INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
+insPT.run('plaat', 't-open', 1);
+insPT.run('plaat', 't-dicht', 2);
+insPT.run('mix', 't-open', 1);
+
+const app = express(); app.use(routes);
+const server = app.listen(0);
+await new Promise((r) => server.once('listening', r));
+const base = `http://127.0.0.1:${server.address().port}`;
+const haal = async (p) => {
+  const r = await fetch(base + p, { headers: { Accept: 'application/activity+json' } });
+  return { status: r.status, body: r.status === 200 ? await r.json() : null };
+};
+
+test('de actor wijst naar de tracks EN de playlists, tracks eerst', async () => {
+  const { body } = await haal('/ap/users/band');
+  assert.deepEqual(body.streams, [
+    'https://test.example/ap/users/band/tracks',
+    'https://test.example/ap/users/band/playlists',
+  ]);
+});
+
+test('de collectie draagt elke OPEN track, ook zonder playlist', async () => {
+  const { body } = await haal('/ap/users/band/tracks');
+  assert.equal(body.type, 'OrderedCollection');
+  assert.equal(body.totalItems, 2, 'twee open tracks; de gesloten telt niet mee');
+  const namen = body.orderedItems.map((a) => a.name);
+  assert.deepEqual(namen, ['Open nummer', 'Zonder playlist'],
+    'ook de track die in geen enkele playlist staat hoort hier');
+  assert.ok(!JSON.stringify(body).includes('Gesloten nummer'), 'een gated titel lekt niet');
+});
+
+test('een track heeft een eigen id en url als Link-array', async () => {
+  const { body } = await haal('/ap/users/band/tracks');
+  const a = body.orderedItems[0];
+  assert.equal(a.id, 'https://test.example/ap/users/band/tracks/t-open');
+  assert.equal(a.type, 'Audio');
+  assert.equal(a.attributedTo, 'https://test.example/ap/users/band');
+  assert.equal(a.duration, 'PT203S');
+  assert.equal(a.summary, 'De Band');
+  assert.deepEqual(a.url, [{
+    type: 'Link', href: 'https://test.example/audio/stream/open.mp3', mediaType: 'audio/mpeg',
+  }], 'de mediaType hoort bij de link, niet bij het object');
+  assert.equal(a.icon.url, 'https://test.example/media/hoes.jpg');
+});
+
+test('los op te halen, met eigen @context', async () => {
+  const { status, body } = await haal('/ap/users/band/tracks/t-open');
+  assert.equal(status, 200);
+  assert.equal(body.id, 'https://test.example/ap/users/band/tracks/t-open');
+  assert.ok(body['@context'], 'standalone draagt zijn eigen context');
+});
+
+test('een gesloten track is AFWEZIG, niet leeg', async () => {
+  assert.equal((await haal('/ap/users/band/tracks/t-dicht')).status, 404);
+  assert.equal((await haal('/ap/users/band/tracks/bestaatniet')).status, 404);
+});
+
+test('dezelfde track in twee playlists is HETZELFDE ding', async () => {
+  const a = (await haal('/ap/users/band/playlists/plaat')).body.orderedItems[0];
+  const b = (await haal('/ap/users/band/playlists/mix')).body.orderedItems[0];
+  assert.equal(a.id, b.id);
+  assert.equal(a.id, 'https://test.example/ap/users/band/tracks/t-open');
+});
+
+test('de playlisthoes springt in voor een track zonder eigen hoes', async () => {
+  // t-los heeft geen cover_url; in de plaat-collectie zit hij niet, dus we
+  // toetsen de terugval via een playlist die hem wel bevat.
+  db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('plaat','t-los',3)").run();
+  const items = (await haal('/ap/users/band/playlists/plaat')).body.orderedItems;
+  const los = items.find((a) => a.name === 'Zonder playlist');
+  assert.equal(los.icon.url, 'https://test.example/media/plaathoes.jpg');
+  // En de track met een EIGEN hoes houdt die.
+  assert.equal(items.find((a) => a.name === 'Open nummer').icon.url, 'https://test.example/media/hoes.jpg');
+});
+
+test.after(() => server.close());
