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());
