Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 4d81de5db2cefacf9f554f61696388ca4fbb4a9d)
+++ src/services/ActivityPubService.js	(revision cf5073b552c7838fa094219d78e0171228901205)
@@ -52,4 +52,13 @@
     value: 'schema:value',
     embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
+    // Wat een track beschrijft en AS2 niet kent (shaer-0nh). Funkwhale zet deze
+    // vier op zijn Audio; het bleken geen eigen verzinsels maar termen die
+    // schema.org gewoon heeft -- en schema.org stond hier al. De SLEUTELS zijn
+    // die van Funkwhale, want daar leest hij op; de BETEKENIS komt van
+    // schema.org, dus we hoeven geen vreemd vocabulaire binnen te halen.
+    license: { '@id': 'schema:license', '@type': '@id' },
+    position: 'schema:position',
+    bitrate: 'schema:bitrate',
+    size: 'schema:contentSize',
     // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
     // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
@@ -1067,4 +1076,5 @@
 // m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
 const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
+     t.position, t.license,
      m.filename, m.storage_path, m.mime_type, m.size`;
 
@@ -1123,4 +1133,14 @@
   const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
   const fn = r.filename || (r.storage_path || '').split('/').pop();
+  // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
+  // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
+  // Zo doet Funkwhale het ook.
+  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);
+  // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
+  // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
+  // de getallen hebben -- liever geen veld dan een verzonnen getal.
+  if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
+
   const a = {
     ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
@@ -1129,12 +1149,46 @@
     name: r.title || 'Audio',
     attributedTo: actorId(base, site.slug),
-    url: [{ type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' }],
+    // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
+    // track moet zelf kunnen zeggen dat hij openbaar is.
+    to: [PUBLIC],
+    url: [bestand],
   };
   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();
+  if (Number(r.position)) a.position = Number(r.position);
+  const lic = licentieUri(r.license);
+  if (lic) a.license = lic;
   const art = abs(r.cover_url || opts.coverFallback || null);
-  if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
+  // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
+  // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
+  if (art) {
+    const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
+    a.icon = plaat;
+    a.image = plaat;
+  }
   return a;
+}
+
+/**
+ * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
+ * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
+ * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
+ * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
+ * een lezer gelooft hem.
+ */
+const LICENTIES = {
+  'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
+  'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
+  'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
+  'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
+  'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
+  'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
+};
+export function licentieUri(waarde) {
+  const s = String(waarde || '').trim();
+  if (!s) return null;
+  if (/^https?:\/\//i.test(s)) return safeUrl(s);   // iemand vulde al een URI in
+  return LICENTIES[s.toLowerCase()] || null;        // "Alle rechten voorbehouden" heeft er geen
 }
 
Index: test/ap-track-fields.test.js
===================================================================
--- test/ap-track-fields.test.js	(revision cf5073b552c7838fa094219d78e0171228901205)
+++ test/ap-track-fields.test.js	(revision cf5073b552c7838fa094219d78e0171228901205)
@@ -0,0 +1,83 @@
+// Onze Audio inhoudelijk gelijk aan die van Funkwhale (shaer-0nh).
+//
+// Vergelijking van dezelfde track aan beide kanten leerde: op elk veld waar AS2
+// iets zegt zijn WIJ conform en zij niet -- en dat is precies een veld,
+// duration. Al hun extra velden bestaan gewoon in schema.org, dat Klonkt al in
+// zijn context had. Dus we konden ze alle vier overnemen zonder concessie: de
+// SLEUTELS zijn die van Funkwhale, de BETEKENIS komt van schema.org.
+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');
+
+db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)')
+  .run('m1', 's1', 'nummer.mp3', 'audio/nummer.mp3', 'audio/mpeg', 1653698);
+db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, cover_url, position, license, fedi_open, created_at)
+            VALUES (?,?,?,?,?,?,?,?,?,1,?)`)
+  .run('t1', 's1', '0METAL MARIO', 'De Band', 103, 'm1', '/media/hoes.jpg', 1, 'CC BY 4.0', '2026-08-08 04:38:34');
+
+const track = () => AP.siteOpenTracks('s1')[0];
+const audio = () => AP.buildTrackAudio(BASE, { slug: 'band' }, track());
+
+test('de bestandsgegevens zitten op de LINK, niet op het object', () => {
+  const a = audio();
+  assert.equal(a.url.length, 1);
+  assert.equal(a.url[0].size, 1653698, 'bytes horen bij deze representatie');
+  assert.equal(a.url[0].bitrate, Math.round((1653698 * 8) / 103), 'afgeleid uit bytes en seconden, niet gegokt');
+  assert.equal(a.size, undefined, 'het NUMMER is geen bestand');
+  assert.equal(a.bitrate, undefined);
+});
+
+test('to staat op het object zelf, niet alleen op de Create', () => {
+  assert.deepEqual(audio().to, ['https://www.w3.org/ns/activitystreams#Public'],
+    'een los opgehaalde track moet zelf kunnen zeggen dat hij openbaar is');
+});
+
+test('position en license komen mee', () => {
+  const a = audio();
+  assert.equal(a.position, 1);
+  assert.equal(a.license, 'http://creativecommons.org/licenses/by/4.0/', 'de tekst uit onze keuzelijst wordt de canonieke URI');
+});
+
+test('de hoes staat onder icon EN image -- allebei AS2-kern', () => {
+  const a = audio();
+  assert.equal(a.icon.url, 'https://test.example/media/hoes.jpg');
+  assert.deepEqual(a.image, a.icon, 'dezelfde hoes, twee namen; wij lazen icon, Funkwhale leest image');
+});
+
+test('duration blijft de spec volgen -- daar wijken we bewust af van Funkwhale', () => {
+  // AS2 zegt MUST xsd:duration, schema.org zegt ISO 8601. Funkwhale stuurt een
+  // getal. Op dit ene veld hebben wij gelijk, dus hier bewegen we niet.
+  assert.equal(audio().duration, 'PT103S');
+});
+
+test('een licentie die we niet kennen levert GEEN veld op', () => {
+  db.prepare("UPDATE audio_tracks SET license = 'Alle rechten voorbehouden' WHERE id = 't1'").run();
+  assert.equal(audio().license, undefined, 'een zelfbedachte licentie-URI is erger dan geen: een lezer gelooft hem');
+  db.prepare("UPDATE audio_tracks SET license = 'https://voorbeeld.example/licentie' WHERE id = 't1'").run();
+  assert.equal(audio().license, 'https://voorbeeld.example/licentie', 'een ingevulde URI gaat wel door');
+  db.prepare("UPDATE audio_tracks SET license = 'CC BY 4.0' WHERE id = 't1'").run();
+});
+
+test('zonder duur of grootte geen verzonnen bitrate', () => {
+  // media.size is NOT NULL, dus 0 als "onbekend" -- dat is ook wat de kolom in
+  // de praktijk krijgt als de grootte niet bepaald kon worden.
+  db.prepare("UPDATE media SET size = 0 WHERE id = 'm1'").run();
+  const a = audio();
+  assert.equal(a.url[0].bitrate, undefined);
+  assert.equal(a.url[0].size, undefined);
+  assert.equal(a.duration, 'PT103S', 'de duur staat er nog wel');
+  db.prepare('UPDATE media SET size = 1653698 WHERE id = ?').run('m1');
+});
Index: test/ap-tracks.test.js
===================================================================
--- test/ap-tracks.test.js	(revision 4d81de5db2cefacf9f554f61696388ca4fbb4a9d)
+++ test/ap-tracks.test.js	(revision cf5073b552c7838fa094219d78e0171228901205)
@@ -75,7 +75,11 @@
   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');
+  // De Link draagt sinds de veldvergelijking met Funkwhale ook de
+  // bestandsgegevens: die horen bij DEZE representatie, niet bij het nummer.
+  assert.equal(a.url.length, 1);
+  assert.equal(a.url[0].type, 'Link');
+  assert.equal(a.url[0].href, 'https://test.example/audio/stream/open.mp3');
+  assert.equal(a.url[0].mediaType, 'audio/mpeg', 'de mediaType hoort bij de link, niet bij het object');
+  assert.equal(a.mediaType, undefined, 'en dus NIET op het object');
   assert.equal(a.icon.url, 'https://test.example/media/hoes.jpg');
 });
