Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision 6fab3f3e28add90b1140273d9a28793236cd7a39)
+++ src/services/music/index.js	(revision cae7bfc26a1a28c757ad9371ff6fce0dbc1530e5)
@@ -332,2 +332,59 @@
   } catch { return null; }
 }
+
+// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────────
+
+/**
+ * Het type van een post afleiden uit de muziek die erin staat.
+ *
+ * DE REGEL GAAT OVER IDENTITEIT, NIET OVER TELLEN (Robins herformulering, 9-8):
+ * een post neemt het type van zijn muziek over als hij precies EEN muzikale
+ * eenheid bevat. Zijn het er meer, dan is de post een post die naar muziek
+ * verwijst, en houden de collecties hun eigen identiteit.
+ *
+ *   losse track(s)                -> playlist   metadata van de post geleend
+ *   een collectie                 -> die soort  metadata van de post geleend
+ *   een collectie + losse tracks  -> album      de losse zijn BONUS-TRACKS
+ *   twee of meer collecties       -> post       NIETS geleend
+ *
+ * Waarom de lening bij de laatste vervalt: die bestaat omdat een collectie soms
+ * dun is -- geen eigen hoes, geen eigen titel. Bij twee is de post niet meer de
+ * drager van EEN identiteit, en vervalt de reden vanzelf. Dezelfde regel zet
+ * zichzelf uit.
+ *
+ * EEN COLLECTIE IS EEN PLAYLIST OF EEN ALBUM-INSLUITING. Robins regel noemde
+ * alleen playlists, maar [[album:naam]] groepeert net zo goed tracks en is
+ * letterlijk een album; hem als losse tracks tellen zou een albumpost tot
+ * playlist maken. Telt hij straks anders, dan is dat hier een regel.
+ */
+export function postMusicType(content) {
+  const c = String(content || '');
+  const uniek = (re) => [...new Set([...c.matchAll(re)].map((m) => m[1].trim()))];
+
+  const playlists = uniek(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g);
+  const albums    = uniek(/\[\[album:([^\]]+)\]\]/g);
+  const tracks    = uniek(/\[\[track:([A-Za-z0-9_-]+)\]\]/g);
+
+  const collecties = [
+    ...playlists.map((id) => ({ soort: 'playlist', id })),
+    ...albums.map((naam) => ({ soort: 'album', naam })),
+  ];
+
+  if (!collecties.length && !tracks.length) return null;          // geen muziek
+
+  if (!collecties.length) {
+    // Ook EEN losse track wordt een playlist: naar buiten toe is er dan altijd
+    // een collectie om naar te wijzen. Hoe Klonkt dat toont is een aparte vraag.
+    return { type: 'playlist', collectie: null, tracks, bonus: [], leentMetadata: true };
+  }
+
+  if (collecties.length === 1) {
+    const c0 = collecties[0];
+    // Losse tracks naast een collectie zijn geen rommelrestje maar bonus-tracks,
+    // en dat maakt het geheel een album.
+    if (tracks.length) return { type: 'album', collectie: c0, tracks: [], bonus: tracks, leentMetadata: true };
+    return { type: c0.soort, collectie: c0, tracks: [], bonus: [], leentMetadata: true };
+  }
+
+  return { type: 'post', collecties, tracks, bonus: [], leentMetadata: false };
+}
Index: test/music-type.test.js
===================================================================
--- test/music-type.test.js	(revision cae7bfc26a1a28c757ad9371ff6fce0dbc1530e5)
+++ test/music-type.test.js	(revision cae7bfc26a1a28c757ad9371ff6fce0dbc1530e5)
@@ -0,0 +1,85 @@
+// Welk soort uitgave is een post? (shaer-cyg)
+//
+// De regel gaat over IDENTITEIT en niet over tellen: een post neemt het type
+// van zijn muziek over als hij precies EEN muzikale eenheid bevat. Zijn het er
+// meer, dan is de post een post die naar muziek verwijst en houden de
+// collecties hun eigen identiteit -- en vervalt de reden om de posttitel en
+// -cover te lenen.
+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');
+dbMod.initializeDatabase();
+const { postMusicType } = await import('../src/services/music/index.js');
+
+test('geen muziek: geen type', () => {
+  assert.equal(postMusicType('<p>gewoon een bericht</p>'), null);
+  assert.equal(postMusicType(''), null);
+  assert.equal(postMusicType(null), null);
+});
+
+test('losse tracks worden een playlist, en lenen de metadata van de post', () => {
+  const r = postMusicType('Twee nieuwe: [[track:a]] en [[track:b]]');
+  assert.equal(r.type, 'playlist');
+  assert.deepEqual(r.tracks, ['a', 'b']);
+  assert.equal(r.leentMetadata, true);
+});
+
+test('ook EEN losse track wordt een playlist', () => {
+  // Naar buiten toe is er dan altijd een collectie om naar te wijzen. Dat
+  // "Playlist" boven een enkel nummer gek leest is een weergavekwestie.
+  assert.equal(postMusicType('[[track:a]]').type, 'playlist');
+});
+
+test('een playlist blijft een playlist', () => {
+  const r = postMusicType('Nieuw: [[playlist:nachtlicht]]');
+  assert.equal(r.type, 'playlist');
+  assert.deepEqual(r.collectie, { soort: 'playlist', id: 'nachtlicht' });
+  assert.equal(r.leentMetadata, true);
+});
+
+test('een album-insluiting is ook een collectie, en telt als album', () => {
+  // Robins regel noemde alleen playlists; [[album:naam]] groepeert net zo goed
+  // tracks. Hem als losse tracks tellen zou een albumpost tot playlist maken.
+  const r = postMusicType('Luister [[album:De Plaat]]');
+  assert.equal(r.type, 'album');
+  assert.deepEqual(r.collectie, { soort: 'album', naam: 'De Plaat' });
+});
+
+test('een collectie MET losse tracks is een album, en die losse zijn bonus', () => {
+  const r = postMusicType('[[playlist:nachtlicht]] plus [[track:x]] en [[track:y]]');
+  assert.equal(r.type, 'album');
+  assert.equal(r.collectie.id, 'nachtlicht');
+  assert.deepEqual(r.bonus, ['x', 'y'], 'geen rommelrestje maar bonus-tracks');
+  assert.deepEqual(r.tracks, [], 'de losse zitten in bonus, niet twee keer');
+  assert.equal(r.leentMetadata, true);
+});
+
+test('TWEE collecties: de post blijft een post en leent niets', () => {
+  const r = postMusicType('[[playlist:een]] en ook [[playlist:twee]]');
+  assert.equal(r.type, 'post');
+  assert.equal(r.collecties.length, 2);
+  assert.equal(r.leentMetadata, false,
+    'bij twee is de post niet meer de drager van EEN identiteit');
+});
+
+test('twee collecties van verschillende soort tellen ook als twee', () => {
+  const r = postMusicType('[[playlist:een]] naast [[album:De Plaat]]');
+  assert.equal(r.type, 'post');
+  assert.equal(r.collecties.length, 2);
+});
+
+test('dezelfde insluiting twee keer is EEN collectie', () => {
+  // Anders maakt een herhaalde shortcode van een albumpost stilletjes een
+  // gewone post.
+  const r = postMusicType('[[playlist:een]] ... en nog eens [[playlist:een]]');
+  assert.equal(r.type, 'playlist');
+  assert.equal(r.collectie.id, 'een');
+});
+
+test('dezelfde track twee keer telt ook maar een keer', () => {
+  assert.deepEqual(postMusicType('[[track:a]] [[track:a]] [[track:b]]').tracks, ['a', 'b']);
+});
