Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision a5d14c7fd745b18c9d8d740542b191932cef000e)
+++ src/services/ActivityPubService.js	(revision 0677bace4cb40bd7d08fdea74f3cb74c579b6229)
@@ -29,5 +29,5 @@
 import Blocklist from './BlocklistService.js';
 import * as Guardianship from './guardianship/index.js';
-import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType } from './ap-core.js';
+import { PUBLIC, AP_CONTEXT, safeUrl, actorId, noteId, guessMediaType, normalizeTags, tagParts } from './ap-core.js';
 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
 export { AP_CONTEXT, actorId, noteId, guessMediaType };
@@ -2973,24 +2973,9 @@
 
 // Normalise a post's tags field (array, JSON-string, or comma-string) to an array.
-function normalizeTags(t) {
-  if (Array.isArray(t)) return t;
-  if (typeof t === 'string') {
-    const s = t.trim(); if (!s) return [];
-    if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* fall through */ } }
-    return s.split(',').map((x) => x.trim()).filter(Boolean);
-  }
-  return [];
-}
+// normalizeTags en tagParts staan sinds shaer-38y in ap-core: music/ heeft ze
+// ook nodig en mag hier niet uit importeren.
 // A tag → { label, slug }. Multi-word tags become CamelCase (#LiveMusic) for the display
 // name (Mastodon hashtags can't contain spaces; CamelCase is the accessibility norm); the
 // slug/href stays lowercase ("livemusic").
-function tagParts(raw) {
-  const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
-  if (!words.length) return null;
-  const slug = words.join('').toLowerCase();
-  if (!slug) return null;
-  const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
-  return { label, slug };
-}
 // Merge a post's tags field + the #hashtags linked inline in its body into one deduped
 // Hashtag tag list (with hrefs to our /tag page).
Index: src/services/ap-core.js
===================================================================
--- src/services/ap-core.js	(revision a5d14c7fd745b18c9d8d740542b191932cef000e)
+++ src/services/ap-core.js	(revision 0677bace4cb40bd7d08fdea74f3cb74c579b6229)
@@ -93,2 +93,33 @@
   })[(e || '').toLowerCase()] || 'image/jpeg';
 }
+
+/**
+ * Het tagveld van een post als lijst. Het staat in de database als JSON-ARRAY
+ * en niet als kommalijst -- op komma's splitsen levert `#["Doen we Niet"` op,
+ * en dat faalt niet, het liegt. Vandaar een echte parser, met de kommavorm als
+ * terugval voor wat er handmatig is ingevuld.
+ */
+export function normalizeTags(t) {
+  if (Array.isArray(t)) return t;
+  if (typeof t === 'string') {
+    const s = t.trim(); if (!s) return [];
+    if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* dan toch als kommalijst */ } }
+    return s.split(',').map((x) => x.trim()).filter(Boolean);
+  }
+  return [];
+}
+
+/**
+ * Een tag -> { label, slug }. Tags van meerdere woorden worden CamelCase
+ * (#LiveMusic) voor de weergavenaam -- een Mastodon-hashtag mag geen spaties
+ * bevatten en CamelCase is daar de toegankelijkheidsnorm; de slug en de href
+ * blijven kleingeschreven ("livemusic").
+ */
+export function tagParts(raw) {
+  const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
+  if (!words.length) return null;
+  const slug = words.join('').toLowerCase();
+  if (!slug) return null;
+  const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
+  return { label, slug };
+}
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision a5d14c7fd745b18c9d8d740542b191932cef000e)
+++ src/services/music/index.js	(revision 0677bace4cb40bd7d08fdea74f3cb74c579b6229)
@@ -14,5 +14,5 @@
 
 import db from '../../config/database.js';
-import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType } from '../ap-core.js';
+import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, normalizeTags, tagParts } from '../ap-core.js';
 import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js';
 
@@ -368,5 +368,5 @@
   // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
   // staan -- dat is een samenvatting, en de posttekst is dat niet.
-  const tekst = (post.excerpt || '').trim();
+  const tekst = tekstVanPost(post);
   if (tekst) obj.content = tekst;
 
@@ -388,19 +388,39 @@
 }
 
-/** De tags van een post als AS2 Hashtags. Zelfde vorm als buildHashtagList. */
+/**
+ * De tags van een post als AS2 Hashtags -- zelfde vorm als buildHashtagList.
+ *
+ * Met normalizeTags en niet met een eigen split: het veld staat als JSON-array
+ * in de database, en op komma's splitsen leverde live `#["Doen we Niet"` op.
+ * Dat is het soort fout dat niet omvalt maar onzin uitlevert.
+ */
 function hashtagsVanPost(base, tagsField) {
-  const ruw = Array.isArray(tagsField)
-    ? tagsField
-    : String(tagsField || '').split(',');
   const uit = [], gezien = new Set();
-  for (const t of ruw) {
-    const label = String(t || '').trim().replace(/^#/, '');
-    if (!label) continue;
-    const slug = label.toLowerCase().replace(/\s+/g, '-');
-    if (gezien.has(slug)) continue;
-    gezien.add(slug);
-    uit.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(slug)}`, name: '#' + label });
+  for (const t of normalizeTags(tagsField)) {
+    const p = tagParts(t);
+    if (!p || gezien.has(p.slug)) continue;
+    gezien.add(p.slug);
+    uit.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
   }
   return uit;
+}
+
+/**
+ * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
+ * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
+ * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
+ * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
+ */
+function tekstVanPost(post) {
+  const excerpt = String(post.excerpt || '').trim();
+  if (excerpt) return excerpt;
+  const kaal = String(post.content || '')
+    .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/&nbsp;/gi, ' ')
+    .replace(/&[a-z#0-9]+;/gi, ' ')
+    .replace(/\s+/g, ' ')
+    .trim();
+  return kaal || null;
 }
 
Index: test/music-uitgave.test.js
===================================================================
--- test/music-uitgave.test.js	(revision a5d14c7fd745b18c9d8d740542b191932cef000e)
+++ test/music-uitgave.test.js	(revision 0677bace4cb40bd7d08fdea74f3cb74c579b6229)
@@ -51,5 +51,5 @@
   excerpt: 'Opgenomen op zolder, in een week.',
   cover: '/media/hoes.jpg',
-  tags: 'lofi, zolder',
+  tags: '["lofi","zolder tapes"]',
 });
 
@@ -74,5 +74,8 @@
   assert.equal(col.image.type, 'Image');
   assert.equal(col.image.url, `${BASE}/media/hoes.jpg`);
-  assert.deepEqual(col.tag.map((t) => t.name), ['#lofi', '#zolder']);
+  // Het tagveld is JSON in de database, en een tag van twee woorden wordt
+  // CamelCase -- zelfde regel als elders, want het is nu dezelfde parser.
+  assert.deepEqual(col.tag.map((t) => t.name), ['#lofi', '#ZolderTapes']);
+  assert.equal(col.tag[1].href, `${BASE}/tag/zoldertapes`);
   assert.equal(col.url, `${BASE}/drie-nieuwe`);
   assert.equal(col.context, `${BASE}/ap/notes/p-los`);
@@ -153,2 +156,23 @@
   assert.equal(M.uitgavePost(SITE, 'tweede'), null);
 });
+
+test('zonder excerpt leent hij de tekst uit het lijf van de post', () => {
+  // Live bleek de excerpt vaak leeg; dan is `content` weglaten slechter dan de
+  // tekst zelf pakken. Shortcodes gaan eruit -- die zijn de muziek, niet het
+  // verhaal erover.
+  const p = maakPost({
+    id: 'p-tekst', slug: 'zonder-excerpt', titel: 'Zonder excerpt',
+    content: '<p>Drie schetsen van vorige week.</p>[[track:t-een]]<div>&nbsp;</div>',
+  });
+  const col = M.buildPostTrackCollection(BASE, site, p);
+  assert.equal(col.content, 'Drie schetsen van vorige week.');
+});
+
+test('en een post die alleen uit shortcodes bestaat leent geen lege tekst', () => {
+  const p = maakPost({
+    id: 'p-kaal', slug: 'alleen-muziek', titel: 'Alleen muziek',
+    content: '[[track:t-twee]]<div> </div>',
+  });
+  const col = M.buildPostTrackCollection(BASE, site, p);
+  assert.equal(col.content, undefined, 'een leeg veld is slechter dan geen veld');
+});
