Index: src/routes/feed.js
===================================================================
--- src/routes/feed.js	(revision fb22f786d3e30b39bdcb4e33be8d481a14fc07e7)
+++ src/routes/feed.js	(revision 47ca083edf66374a02b2ec144b35190119a785fd)
@@ -12,4 +12,5 @@
 import express from 'express';
 import db from '../config/database.js';
+import { siteOpenTracks } from '../services/ActivityPubService.js';
 
 const router = express.Router();
@@ -50,9 +51,49 @@
   const base = origin + (res.locals.siteUrlBase || '');
   const posts = postsForFeed(site.id);
+
+  // De tracks die deze site aan de federatie heeft opengezet, elk als eigen
+  // item met een <enclosure> (shaer-0nh). Dat laatste is wat een podcast-app
+  // zoekt: zonder enclosure is een feed voor hem leeg, hoe veel items er ook
+  // in staan -- en de actor adverteert deze feed nu juist als kanaal-feed.
+  //
+  // EEN ITEM PER TRACK, want RSS 2.0 staat maar EEN enclosure per item toe.
+  // Een album in een item proppen zou betekenen dat er van vijf nummers vier
+  // verdwijnen. Dezelfde keuze als in de outbox: de post is het bericht, de
+  // track is de publicatie.
+  //
+  // Welke tracks open zijn beslist de AP-service, niet deze route: dat is een
+  // poortregel en die hoort op een plek te staan.
+  const tracks = siteOpenTracks(site.id);
   const lastBuild = posts[0]?.published_at || new Date().toISOString();
+
+  const wanneer = (d) => { const t = Date.parse(d); return Number.isNaN(t) ? 0 : t; };
+  const items = [
+    ...posts.map((p) => ({ op: wanneer(p.published_at), xml: `    <item>
+      <title>${escapeXml(p.title || '(untitled)')}</title>
+      <link>${escapeXml(base + '/' + p.slug)}</link>
+      <guid isPermaLink="true">${escapeXml(base + '/' + p.slug)}</guid>
+      <pubDate>${new Date(p.published_at).toUTCString()}</pubDate>
+      <author>${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')}</author>
+      <description>${escapeXml(p.excerpt || '')}</description>
+    </item>` })),
+    ...tracks.map((t) => {
+      const fn = t.filename || (t.storage_path || '').split('/').pop();
+      // Geen <link>: Klonkt heeft geen trackpagina, en een post over vijf
+      // nummers is niet de pagina van dit ene nummer. De guid is daarom geen
+      // permalink maar de stabiele AP-id van de track.
+      return { op: wanneer(t.created_at), xml: `    <item>
+      <title>${escapeXml(t.title || 'Audio')}</title>
+      <guid isPermaLink="false">${escapeXml(`${base}/ap/users/${encodeURIComponent(site.slug)}/tracks/${encodeURIComponent(t.id)}`)}</guid>
+      <pubDate>${new Date(t.created_at || Date.now()).toUTCString()}</pubDate>
+      <description>${escapeXml(t.artist || '')}</description>
+      <enclosure url="${escapeXml(`${base}/audio/stream/${encodeURIComponent(fn)}`)}" length="${Number(t.size) || 0}" type="${escapeXml(t.mime_type || 'audio/mpeg')}" />${t.duration ? `
+      <itunes:duration>${Math.round(t.duration)}</itunes:duration>` : ''}
+    </item>` };
+    }),
+  ].sort((a, b) => b.op - a.op).map((x) => x.xml).join('\n');
 
   res.set('Content-Type', 'application/rss+xml; charset=utf-8');
   res.send(`<?xml version="1.0" encoding="UTF-8"?>
-<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
   <channel>
     <title>${escapeXml(site.title)}</title>
@@ -62,12 +103,5 @@
     <lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
     <atom:link href="${escapeXml(base + '/feed.xml')}" rel="self" type="application/rss+xml" />
-${posts.map(p => `    <item>
-      <title>${escapeXml(p.title || '(untitled)')}</title>
-      <link>${escapeXml(base + '/' + p.slug)}</link>
-      <guid isPermaLink="true">${escapeXml(base + '/' + p.slug)}</guid>
-      <pubDate>${new Date(p.published_at).toUTCString()}</pubDate>
-      <author>${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')}</author>
-      <description>${escapeXml(p.excerpt || '')}</description>
-    </item>`).join('\n')}
+${items}
   </channel>
 </rss>`);
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision fb22f786d3e30b39bdcb4e33be8d481a14fc07e7)
+++ src/services/ActivityPubService.js	(revision 47ca083edf66374a02b2ec144b35190119a785fd)
@@ -1059,6 +1059,7 @@
 // open deel: een eerlijke telling over wat er werkelijk in de collectie staat,
 // niet over wat wij thuis in de kast hebben.
+// 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,
-     m.filename, m.storage_path, m.mime_type`;
+     m.filename, m.storage_path, m.mime_type, m.size`;
 
 export function playlistOpenTracks(playlistId) {
Index: test/feed-enclosures.test.js
===================================================================
--- test/feed-enclosures.test.js	(revision 47ca083edf66374a02b2ec144b35190119a785fd)
+++ test/feed-enclosures.test.js	(revision 47ca083edf66374a02b2ec144b35190119a785fd)
@@ -0,0 +1,86 @@
+// De RSS-feed draagt de opengezette tracks als <enclosure> (shaer-0nh).
+//
+// Aanleiding: de actor adverteert /feed.xml als kanaal-feed, maar die feed was
+// een blogfeed -- titels en tekst, nul enclosures. Een podcast-app zoekt
+// precies daarop, dus voor hem was de feed leeg hoe veel items er ook in
+// stonden. De wegwijzer stond er, de inhoud niet.
+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 express = (await import('express')).default;
+const feed = (await import('../src/routes/feed.js')).default;
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'zanger', 'zanger@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 posts (id, site_id, author_id, slug, title, excerpt, content, status, published_at)
+            VALUES (?,?,?,?,?,?,?,'published',?)`)
+  .run('p1', 's1', 'u1', 'oud-bericht', 'Oud bericht', 'kort', '<p>x</p>', '2026-01-01T00:00:00Z');
+db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, excerpt, content, status, published_at)
+            VALUES (?,?,?,?,?,?,?,'published',?)`)
+  .run('p2', 's1', 'u1', 'nieuw-bericht', 'Nieuw bericht', 'kort', '<p>y</p>', '2026-06-01T00:00:00Z');
+
+const insM = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)');
+const insT = db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, fedi_open, created_at) VALUES (?,?,?,?,?,?,?,?)');
+insM.run('m1', 's1', 'open.mp3', 'audio/open.mp3', 'audio/mpeg', 4210688);
+insM.run('m2', 's1', 'geheim.mp3', 'audio/geheim.mp3', 'audio/mpeg', 999);
+insM.run('m3', 's1', 'tweede.mp3', 'audio/tweede.mp3', 'audio/mpeg', 1024);
+insT.run('t1', 's1', 'Het open nummer', 'De Band', 203, 'm1', 1, '2026-03-01 12:00:00');
+insT.run('t2', 's1', 'Achter de poort', 'De Band', 100, 'm2', 0, '2026-04-01 12:00:00');
+insT.run('t3', 's1', 'Tweede open', 'De Band', 90, 'm3', 1, '2026-07-01 12:00:00');
+
+const app = express();
+app.use((req, res, next) => {
+  res.locals.site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+  res.locals.siteUrlBase = '';
+  next();
+});
+app.use(feed);
+const server = app.listen(0);
+await new Promise((r) => server.once('listening', r));
+const xml = await (await fetch(`http://127.0.0.1:${server.address().port}/feed.xml`)).text();
+
+test('elke open track levert precies een enclosure', () => {
+  const enclosures = xml.match(/<enclosure[^>]*>/g) || [];
+  assert.equal(enclosures.length, 2, 'twee open tracks, twee enclosures');
+  const items = xml.match(/<item>/g) || [];
+  assert.equal(items.length, 4, 'twee posts en twee tracks, elk een eigen item');
+});
+
+test('de enclosure draagt url, lengte in bytes en het echte mime-type', () => {
+  const e = (xml.match(/<enclosure[^>]*open\.mp3[^>]*>/) || [])[0];
+  assert.ok(e, 'de open track heeft een enclosure');
+  assert.match(e, /url="[^"]*\/audio\/stream\/open\.mp3"/);
+  assert.match(e, /length="4210688"/, 'de bytes komen uit media.size');
+  assert.match(e, /type="audio\/mpeg"/);
+});
+
+test('de duur staat erbij, in seconden', () => {
+  assert.match(xml, /<itunes:duration>203<\/itunes:duration>/);
+  assert.match(xml, /xmlns:itunes="http:\/\/www\.itunes\.com\/dtds\/podcast-1\.0\.dtd"/,
+    'de namespace is gedeclareerd, anders is het geen geldige XML voor een lezer');
+});
+
+test('een gesloten track staat er niet in, en lekt titel noch bestandsnaam', () => {
+  assert.ok(!xml.includes('Achter de poort'));
+  assert.ok(!xml.includes('geheim.mp3'));
+});
+
+test('de guid van een track is geen permalink maar zijn AP-id', () => {
+  assert.match(xml, /<guid isPermaLink="false">[^<]*\/ap\/users\/band\/tracks\/t1<\/guid>/);
+});
+
+test('posts en tracks staan door elkaar op datum, nieuwste eerst', () => {
+  const titels = [...xml.matchAll(/<title>([^<]*)<\/title>/g)].map((m) => m[1]).slice(1); // [0] is de kanaaltitel
+  assert.deepEqual(titels, ['Tweede open', 'Nieuw bericht', 'Het open nummer', 'Oud bericht']);
+});
+
+test.after(() => server.close());
