Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 553bd709e5ee181b98f0d113140fca1e9431f446)
+++ src/services/ActivityPubService.js	(revision bdcb3a3bace9358b794b21d3bbb1462807f22d06)
@@ -56,4 +56,12 @@
     // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
     votersCount: 'toot:votersCount',
+    // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
+    // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
+    // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
+    // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
+    // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
+    // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
+    // antwoordt.
+    category: { '@id': 'https://funkwhale.audio/ns#category' },
     // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
     // module (src/services/guardianship/).
@@ -173,4 +181,104 @@
 export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
 
+/** Eén Link uit een AS2 `url` kiezen op mediaType. Een `url` mag een string,
+ *  een Link of een array van beide zijn; dit is de enige plek die dat weet. */
+function pickLink(url, test) {
+  const links = Array.isArray(url) ? url : (url ? [url] : []);
+  for (const l of links) {
+    const href = safeUrl(typeof l === 'string' ? l : (l && l.href));
+    const mt = (l && typeof l === 'object' && l.mediaType) || '';
+    if (href && test(mt)) return { href, mediaType: mt };
+  }
+  return null;
+}
+
+/**
+ * De `url` van de actor als kanaal (shaer-0nh): de webpagina en, als die er is,
+ * de RSS-feed ernaast.
+ *
+ * De RSS-link gaat er ALLEEN in voor de site waar de instance op gepind staat.
+ * Sinds hub-modus verdween serveert routes/feed.js `/feed.xml` van de primaire
+ * site en bestaat `/user/<slug>` niet meer als route; een feed-link voor een
+ * andere site zou naar de verkeerde feed wijzen. Liever een link minder dan een
+ * link die iemand anders' muziek belooft.
+ */
+export function channelUrls(base, site) {
+  const isPrimair = site.slug === site.primary_slug;
+  const pagina = `${base}/${isPrimair ? '' : 'user/' + encodeURIComponent(site.slug)}`;
+  const uit = [{ type: 'Link', href: pagina, mediaType: 'text/html' }];
+  if (isPrimair) uit.push({ type: 'Link', href: `${base}/feed.xml`, mediaType: 'application/rss+xml' });
+  return uit;
+}
+
+/**
+ * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
+ * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
+ * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
+ * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
+ */
+function channelCategory(site) {
+  try {
+    return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? LIMIT 1').get(site.id) ? 'music' : null;
+  } catch { return null; }
+}
+
+/**
+ * Welke objectsoorten deze inbox in de tijdlijn opneemt.
+ *
+ * `Audio` staat erbij sinds de kanaalbeslissing (shaer-0nh): een Funkwhale-
+ * kanaal stuurt Create(Audio), geen Note. Uitbreiden gebeurt HIER en in
+ * timelineFields -- en uitdrukkelijk NIET door vreemde soorten tot Note om te
+ * vormen. Een Audio is geen Note, en die soort willen we kunnen blijven zien.
+ */
+const TIJDLIJN_SOORTEN = new Set(['Note', 'Article', 'Question', 'Audio']);
+
+/**
+ * Wat de tijdlijn van een binnengekomen object nodig heeft, PER SOORT: de
+ * inhoud-HTML, de bijlagen voor media_json, en de link van het item.
+ *
+ * Eén plek, zodat een nieuwe soort erbij een tak is en geen speurtocht. De
+ * Krant rendert media_json al naar soort -- audio/* wordt een speler -- dus een
+ * track komt vanzelf als echte speler binnen zonder dat de weergave iets van
+ * Funkwhale hoeft te weten.
+ */
+export function timelineFields(o) {
+  // De hoes: een `image` op het object. Bij een Note alleen als terugval (daar
+  // is het de kaart-afbeelding van een player-post), bij een Audio altijd,
+  // want daar IS het de albumhoes.
+  const hoes = () => {
+    if (!o.image) return null;
+    const im = Array.isArray(o.image) ? o.image[0] : o.image;
+    const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
+    return iu ? { url: iu, type: (im && im.mediaType) || 'image/jpeg' } : null;
+  };
+
+  if (o.type === 'Audio') {
+    const geluid = pickLink(o.url, (mt) => /^audio\//i.test(mt));
+    // De webpagina van de track. Zonder mediaType is dat de veilige aanname:
+    // er een speler op zetten zou een HTML-pagina als geluid aanbieden.
+    const pagina = pickLink(o.url, (mt) => /^text\/html/i.test(mt)) || pickLink(o.url, (mt) => !mt);
+    const atts = [];
+    const h = hoes(); if (h) atts.push(h);              // eerst kijken, dan luisteren
+    if (geluid) atts.push({ url: geluid.href, type: geluid.mediaType || 'audio/mpeg' });
+    // Een Audio heeft geen `content`; de titel is wat er te lezen valt. Door de
+    // sanitizer, want hij komt van een vreemde server.
+    return {
+      html: o.name ? HtmlSanitizerService.sanitize(`<p>${o.name}</p>`) : '',
+      atts,
+      url: pagina ? pagina.href : null,
+    };
+  }
+
+  // Note / Article / Question -- ongewijzigd gedrag.
+  const atts = (Array.isArray(o.attachment) ? o.attachment : [])
+    .map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' }))
+    .filter((m) => m.url);
+  if (!atts.some((m) => !m.type || /image/i.test(m.type))) {
+    const h = hoes(); if (h) atts.push(h);
+  }
+  const pagina = pickLink(o.url, () => true);
+  return { html: HtmlSanitizerService.sanitize(o.content || ''), atts, url: pagina ? pagina.href : null };
+}
+
 export function buildActor(base, site) {
   const id = actorId(base, site.slug);
@@ -187,5 +295,10 @@
     name: site.title || site.slug,
     summary: site.tagline || site.description || '',
-    url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
+    // Een Link-ARRAY in plaats van een kale string (shaer-0nh): zo adverteert
+    // een kanaal zichzelf, en zo vindt een podcast-app de feed. De text/html
+    // staat VOORAAN, want een lezer die maar één url verwacht pakt de eerste --
+    // dezelfde vorm die Funkwhale in productie met Mastodon uitwisselt.
+    url: channelUrls(base, site),
+    ...(channelCategory(site) ? { category: channelCategory(site) } : {}),
     manuallyApprovesFollowers: isWard,
     discoverable: true,
@@ -2194,5 +2307,5 @@
 
   // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
-  if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
+  if (type === 'Create' && act.object && TIJDLIJN_SOORTEN.has(act.object.type)) {
     const o = act.object;
     // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
@@ -2247,13 +2360,5 @@
       if (subs.length) {
         const ai = actorInfo(await resolveActor(actorUri), actorUri);
-        const html = HtmlSanitizerService.sanitize(o.content || '');
-        const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
-        // Fallback cover: a Note's `image` (set when the attachment was suppressed
-        // for a player-card post, e.g. hosted-audio posts).
-        if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
-          const _im = Array.isArray(o.image) ? o.image[0] : o.image;
-          const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
-          if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
-        }
+        const { html, atts: _atts, url: _url } = timelineFields(o);
         const media = JSON.stringify(_atts);
         const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
@@ -2263,5 +2368,5 @@
         // the timeline).
         for (const s of subs) {
-          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
+          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
           // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
           if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
@@ -5790,4 +5895,5 @@
   AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions,
   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
+  channelUrls, timelineFields,
   buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
Index: test/channel-actor.test.js
===================================================================
--- test/channel-actor.test.js	(revision bdcb3a3bace9358b794b21d3bbb1462807f22d06)
+++ test/channel-actor.test.js	(revision bdcb3a3bace9358b794b21d3bbb1462807f22d06)
@@ -0,0 +1,100 @@
+// De Klonkt-actor als kanaal, en Audio als eigen soort in de tijdlijn
+// (shaer-0nh, Robins besluit 7-8).
+//
+// Twee kanten. Uitgaand: de actor adverteert zijn webpagina en zijn RSS-feed
+// als Link-array, met een category zodra er muziek is. Inkomend: een
+// Create(Audio) van een kanaal wordt een tijdlijnrij met een echte speler --
+// zonder de Audio tot Note om te vormen, want die soort willen we houden.
+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 site = () => db.prepare("SELECT s.*, (SELECT slug FROM sites WHERE is_primary = 1) AS primary_slug FROM sites s WHERE s.id = 's1'").get();
+
+test('de actor draagt zijn webpagina en zijn RSS-feed als Link-array', () => {
+  const a = AP.buildActor(BASE, site());
+  assert.ok(Array.isArray(a.url), 'url is een array, geen string');
+  assert.deepEqual(a.url[0], { type: 'Link', href: 'https://test.example/', mediaType: 'text/html' },
+    'de webpagina staat VOORAAN: wie er maar een verwacht, pakt de eerste');
+  assert.deepEqual(a.url[1], { type: 'Link', href: 'https://test.example/feed.xml', mediaType: 'application/rss+xml' });
+});
+
+test('zonder muziek geen category -- liever geen label dan een verkeerd label', () => {
+  assert.equal(AP.buildActor(BASE, site()).category, undefined);
+});
+
+test('met muziek is de category "music"', () => {
+  db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,1)')
+    .run('m1', 's1', 'a.mp3', 'audio/a.mp3', 'audio/mpeg');
+  db.prepare('INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES (?,?,?,?)')
+    .run('t1', 's1', 'Een nummer', 'm1');
+  assert.equal(AP.buildActor(BASE, site()).category, 'music');
+});
+
+// ── inkomend ────────────────────────────────────────────────────────────────
+
+const AUDIO = {
+  id: 'https://audio.example/federation/music/tracks/1',
+  type: 'Audio',
+  name: '0METAL MARIO - PINK - 140 bpm',
+  duration: 'PT103S',
+  published: '2026-08-07T12:00:00Z',
+  image: { type: 'Image', url: 'https://audio.example/covers/1.jpg', mediaType: 'image/jpeg' },
+  url: [
+    { type: 'Link', href: 'https://audio.example/library/tracks/1', mediaType: 'text/html' },
+    { type: 'Link', href: 'https://audio.example/media/1.mp3', mediaType: 'audio/mpeg' },
+  ],
+};
+
+test('een Audio blijft een Audio en levert een speler plus hoes', () => {
+  const f = AP.timelineFields(AUDIO);
+  assert.equal(AUDIO.type, 'Audio', 'het bronobject is niet tot Note omgevormd');
+  assert.equal(f.url, 'https://audio.example/library/tracks/1', 'de text/html-link is de link van het item');
+  assert.deepEqual(f.atts, [
+    { url: 'https://audio.example/covers/1.jpg', type: 'image/jpeg' },
+    { url: 'https://audio.example/media/1.mp3', type: 'audio/mpeg' },
+  ], 'eerst kijken, dan luisteren -- en audio/* maakt er in de Krant een speler van');
+  assert.match(f.html, /0METAL MARIO/, 'de titel is de inhoud; een Audio heeft geen content');
+});
+
+test('een titel met HTML erin gaat door de sanitizer', () => {
+  const f = AP.timelineFields({ ...AUDIO, name: 'Track <script>alert(1)</script>' });
+  assert.ok(!/<script/i.test(f.html), 'geen script uit een vreemde titel');
+});
+
+test('een Audio zonder audio-link levert geen speler, maar breekt ook niet', () => {
+  const f = AP.timelineFields({ ...AUDIO, url: [{ type: 'Link', href: 'https://audio.example/x', mediaType: 'text/html' }] });
+  assert.ok(!f.atts.some((a) => /^audio\//.test(a.type)), 'niets om af te spelen');
+  assert.equal(f.url, 'https://audio.example/x');
+});
+
+test('een kale string-url telt als webpagina, nooit als geluid', () => {
+  // Zonder mediaType is een speler eropzetten fout: dan bieden we een
+  // HTML-pagina als audiobestand aan.
+  const f = AP.timelineFields({ ...AUDIO, url: 'https://audio.example/los' });
+  assert.equal(f.url, 'https://audio.example/los');
+  assert.ok(!f.atts.some((a) => /^audio\//.test(a.type)));
+});
+
+test('een gewone Note gedraagt zich onveranderd', () => {
+  const f = AP.timelineFields({
+    id: 'https://elders.example/n/1', type: 'Note', content: '<p>hallo</p>',
+    url: 'https://elders.example/n/1',
+    attachment: [{ type: 'Document', url: 'https://elders.example/p.jpg', mediaType: 'image/jpeg' }],
+  });
+  assert.match(f.html, /hallo/);
+  assert.deepEqual(f.atts, [{ url: 'https://elders.example/p.jpg', type: 'image/jpeg' }]);
+  assert.equal(f.url, 'https://elders.example/n/1');
+});
