Index: src/assets/js/shared/post-music-type.js
===================================================================
--- src/assets/js/shared/post-music-type.js	(revision 8ed36de2a9b6b822f55c28663bec1275f43fb70d)
+++ src/assets/js/shared/post-music-type.js	(revision b0ccd0d9b11ffb2a364ca71fd3a63aeff057890e)
@@ -21,5 +21,9 @@
  *   een collectie                 -> die soort  metadata van de post geleend
  *   een collectie + losse tracks  -> album      de losse zijn BONUS-TRACKS
+ *   een MIXTAPE + losse tracks    -> mixtape    de losse zijn BONUS-TRACKS
  *   twee of meer collecties       -> post       NIETS geleend
+ *
+ * Die vierde regel is de uitzondering op de derde en staat er met opzet naast:
+ * bonustracks maken van een uitgave een album, maar een bandje is geen uitgave.
  *
  * Waarom de lening bij de laatste vervalt: die bestaat omdat een collectie soms
@@ -39,4 +43,14 @@
 // insluit, telt hier niet mee. Anders zou een shortcode die niets oplevert wel
 // het type van de post kunnen bepalen.
+// De soorten die een playlist kan zijn, en dit is de ENIGE lijst. PlaylistService
+// en music/index.js halen hem hier op, precies omdat het andersom niet kan:
+// deze module draait ook in de browser en mag niets van de database weten.
+//
+// Waarom dat uitmaakt: bij twee soorten stond de keuze vijf keer als
+// `x === 'playlist' ? 'playlist' : 'album'` verspreid over drie bestanden. Zo'n
+// vorm valt niet om bij een derde soort, hij slikt hem -- een mixtape werd
+// stilzwijgend een album en ging als Album de deur uit.
+export const SOORTEN = ['album', 'playlist', 'mixtape'];
+
 const RE_PLAYLIST = /\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi;
 const RE_ALBUM    = /\[\[album:([^\]]+)\]\]/g;
@@ -64,5 +78,5 @@
   const uitPlaylists = playlists.map((id) => {
     const kind = zoek(id);
-    if (kind !== 'album' && kind !== 'playlist') { onbekend.push(id); return null; }
+    if (!SOORTEN.includes(kind)) { onbekend.push(id); return null; }
     return { soort: kind, id };
   }).filter(Boolean);
@@ -87,4 +101,14 @@
   if (collecties.length === 1) {
     const c0 = collecties[0];
+    // EEN MIXTAPE BLIJFT EEN MIXTAPE, ook met losse tracks erbij. De regel
+    // hieronder maakt van collectie + losse tracks een album met bonustracks, en
+    // dat klopt voor een uitgave: extra nummers bij een plaat zijn bonus. Een
+    // bandje is geen uitgave. Er een album van maken omdat er een los nummer
+    // naast staat zou het ding hernoemen op grond van iets wat er niet bij
+    // hoort. Album en playlist houden bewust hun oude gedrag: dat zijn
+    // bestaande posts en die mogen hier niet stilletjes van soort wisselen.
+    if (c0.soort === 'mixtape') {
+      return { type: 'mixtape', collectie: c0, tracks: [], bonus: tracks, leentMetadata: true };
+    }
     // Losse tracks naast een collectie zijn geen rommelrestje maar bonus-tracks,
     // en dat maakt het geheel een album.
Index: src/services/PlaylistService.js
===================================================================
--- src/services/PlaylistService.js	(revision 8ed36de2a9b6b822f55c28663bec1275f43fb70d)
+++ src/services/PlaylistService.js	(revision b0ccd0d9b11ffb2a364ca71fd3a63aeff057890e)
@@ -14,4 +14,5 @@
 import db from '../config/database.js';
 import { v4 as uuid } from 'uuid';
+import { SOORTEN } from '../assets/js/shared/post-music-type.js';
 
 /**
@@ -41,4 +42,29 @@
   return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(s) ? s : null;
 }
+
+/**
+ * De drie soorten die een playlist kan zijn, op EEN plek.
+ *
+ * Stond eerder vijf keer als `data.kind === 'playlist' ? 'playlist' : 'album'`
+ * verspreid over dit bestand. Met twee soorten kon dat nog; bij een derde is
+ * het een fout die staat te wachten, want een van de vijf vergeten betekent dat
+ * een mixtape stilletjes als album wordt opgeslagen en als Album de deur uit
+ * gaat.
+ *
+ * Album blijft de terugval: een onbekende waarde hoort niet stilzwijgend iets
+ * nieuws te worden. De lijst zelf staat in de gedeelde pure module, want de
+ * editor in de browser moet dezelfde drie kennen.
+ */
+export function normKind(v) {
+  const s = String(v == null ? '' : v).trim().toLowerCase();
+  return SOORTEN.includes(s) ? s : 'album';
+}
+
+/**
+ * Draagt deze soort uitgavegegevens? Alleen een album. Een afspeellijst en een
+ * mixtape hebben geen uitgavedatum en geen release-id: het zijn samenstellingen
+ * van andermans of eigen werk, geen uitgave op zichzelf.
+ */
+export const isUitgave = (kind) => normKind(kind) === 'album';
 
 class PlaylistService {
@@ -92,5 +118,5 @@
       year: r.year || 0,
       cover: r.cover_url || '',
-      kind: r.kind || 'album',
+      kind: normKind(r.kind),
       release_date: r.release_date || '',
       mb_release_id: r.mb_release_id || '',
@@ -154,5 +180,5 @@
       year: p.year || 0,
       cover: p.cover_url || fallbackCover,
-      kind: (p.kind === 'playlist') ? 'playlist' : 'album',
+      kind: normKind(p.kind),
       // created_at hoort erbij omdat de AP-kant er `published` van maakt. Zonder
       // dit veld viel buildAlbumObject terug op 1970, en dat stond op 16-8
@@ -162,6 +188,6 @@
       // maar dit is de plek waar de editor leest en die mag niet afhangen van
       // wat er toevallig in de kolom stond.
-      release_date: p.kind === 'playlist' ? '' : (p.release_date || ''),
-      mb_release_id: p.kind === 'playlist' ? '' : (p.mb_release_id || ''),
+      release_date: isUitgave(p.kind) ? (p.release_date || '') : '',
+      mb_release_id: isUitgave(p.kind) ? (p.mb_release_id || '') : '',
       tracks: mappedTracks,
     };
@@ -178,5 +204,5 @@
     const id = this.generateId(siteId, title);
     const now = new Date().toISOString();
-    const kind = data.kind === 'playlist' ? 'playlist' : 'album';
+    const kind = normKind(data.kind);
 
     // Alleen een UITGAVE draagt deze twee. Een afspeellijst heeft geen
@@ -186,5 +212,5 @@
     // voor de post-editor -- en dan staat er stille rommel op een mixtape die
     // later als Album de deur uit gaat.
-    const uitgave = kind === 'album';
+    const uitgave = isUitgave(kind);
     const releaseDate = uitgave ? normDatum(data.release_date) : null;
     const mbRelease = uitgave ? normMbid(data.mb_release_id) : null;
@@ -229,6 +255,6 @@
     // val terug op wat er ligt.
     const nieuwKind = Object.prototype.hasOwnProperty.call(data, 'kind')
-      ? (data.kind === 'playlist' ? 'playlist' : 'album')
-      : (existing.kind || 'album');
+      ? normKind(data.kind)
+      : normKind(existing.kind);
     if (Object.prototype.hasOwnProperty.call(data, 'title')) {
       const v = String(data.title || '').trim();
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision 8ed36de2a9b6b822f55c28663bec1275f43fb70d)
+++ src/services/music/index.js	(revision b0ccd0d9b11ffb2a364ca71fd3a63aeff057890e)
@@ -15,5 +15,5 @@
 import db from '../../config/database.js';
 import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection, isMbid } from '../ap-core.js';
-import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js';
+import { afleidenUitInsluitingen, ingeslotenPlaylists, SOORTEN } from '../../assets/js/shared/post-music-type.js';
 // De luisteraars horen bij de muziekkant; hier doorgegeven zodat
 // ActivityPubService niet in een submap hoeft te grijpen.
@@ -495,4 +495,62 @@
  * een heeft.
  */
+/**
+ * Het bandje: EEN object, samengesteld uit de nummers van een playlist.
+ *
+ * Waarom het een eigen soort is en geen album met een ander jasje. Een album is
+ * een uitgave: het heeft een uitgavedatum, een release-id, en de nummers
+ * bestaan er los van. Een mixtape is het omgekeerde -- de volgorde IS het werk,
+ * en de nummers zijn er onderdelen van. Vandaar `orderedItems` op het object
+ * zelf in plaats van een collectie ernaast, en vandaar geen `released` en geen
+ * `musicbrainzId`: die zouden beweren dat dit een uitgave is.
+ *
+ * GEEN EIGEN `url`, en dat is een keuze van Robin (21-8) met een prijs die het
+ * waard is om hier op te schrijven. Het bandje is een logische omhulling, geen
+ * gerenderd bestand: er wordt niets samengevoegd. Een ontvanger die `Mixtape`
+ * niet kent heeft dus geen stream om te spelen. Dat is bewust -- de nummers
+ * staan er stuk voor stuk in, met hun eigen id en hun eigen url, dus er gaat
+ * niets verloren; het kost alleen een consument die het type wel begrijpt.
+ *
+ * `type` is een STRING en geen array. Dat is geen slordigheid maar een geleerde
+ * les: er stond bij de playlist-collectie ooit ['OrderedCollection', 'Album'],
+ * geldig AS2 en werkelijk allebei, en een lezer die `type` als tekst uitpakt
+ * (Shaer doet dat) verloor daarmee stil het hele object.
+ */
+export function buildMixtapeObject(base, site, pl, rows) {
+  if (!pl) return null;
+  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
+  const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
+  const wanneer = postDatum ? postDatum.toISOString()
+    : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
+  // Dezelfde lening als bij het album: de post die het bandje uitbrengt geeft
+  // zijn titel, en de eigen titel blijft als alsoKnownAs staan.
+  const titel = (pl._post && pl._post.title) || pl.title;
+  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: pl.cover_url || null }));
+  const tape = {
+    type: 'Mixtape',
+    id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
+    name: titel,
+    ...(wanneer ? { published: wanneer } : {}),
+    attributedTo: actorId(base, site.slug),
+    artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
+    // De kant die het bandje maakt: eerst dit nummer, dan dat. Vooruit en
+    // achteruit is de speler; de volgorde is het object.
+    totalItems: items.length,
+    orderedItems: items,
+  };
+  if (titel !== pl.title) tape.alsoKnownAs = pl.title;
+  // De speelduur van het geheel, want dat is wat een bandje heeft: een lengte.
+  // Alleen als we van ELK nummer de duur kennen -- een som met gaten erin is
+  // een verzonnen getal, en die zetten we hier niet neer (zelfde regel als bij
+  // de bitrate van een track).
+  const duren = (rows || []).map((r) => Number(r.duration) || 0);
+  if (duren.length && duren.every((d) => d > 0)) {
+    tape.duration = `PT${Math.round(duren.reduce((a, b) => a + b, 0))}S`;
+  }
+  const hoes = abs(pl.cover_url || null);
+  if (hoes) tape.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
+  return tape;
+}
+
 export function buildAlbumObject(base, site, pl) {
   if (!pl) return null;
@@ -580,8 +638,19 @@
   // album, dan leest hij deze velden gewoon. En het object dat hij echt gebruikt
   // staat toch al ingesloten op de track.
-  if ((playlist.kind || 'album') === 'album') {
+  const soort = SOORTEN.includes(playlist.kind) ? playlist.kind : 'album';
+  if (soort === 'album') {
     const album = buildAlbumObject(base, site, { ...playlist, _post: post });
     for (const veld of ['published', 'released', 'musicbrainzId', 'artist_credit', 'image']) {
       if (album[veld] !== undefined) out[veld] = album[veld];
+    }
+  }
+  // Een mixtape draagt hier zijn eigen velden, om dezelfde reden als het album:
+  // dit adres is waar een lezer terechtkomt die het bandje wil ophalen, en dan
+  // hoort er hetzelfde te staan als in het ingesloten object. `type` blijft ook
+  // hier OrderedCollection -- zie de uitleg hierboven over Shaer.
+  if (soort === 'mixtape') {
+    const tape = buildMixtapeObject(base, site, { ...playlist, _post: post }, rows);
+    for (const veld of ['published', 'artist_credit', 'image', 'duration', 'alsoKnownAs']) {
+      if (tape[veld] !== undefined) out[veld] = tape[veld];
     }
   }
@@ -801,7 +870,7 @@
 
 /**
- * De gekozen soort van een playlist: 'album' | 'playlist', of null als hij niet
- * (op deze site) bestaat. Zelfde normalisatie als PlaylistService: alles wat
- * geen 'playlist' zegt is een album.
+ * De gekozen soort van een playlist: 'album' | 'playlist' | 'mixtape', of null
+ * als hij niet (op deze site) bestaat. Zelfde lijst als PlaylistService, via
+ * de gedeelde pure module -- alles wat er niet in staat is een album.
  */
 function playlistKind(id, siteId) {
@@ -810,5 +879,5 @@
     const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
     if (!r) return null;
-    return r.kind === 'playlist' ? 'playlist' : 'album';
+    return SOORTEN.includes(r.kind) ? r.kind : 'album';
   } catch { return null; }
 }
Index: test/mixtape.test.js
===================================================================
--- test/mixtape.test.js	(revision b0ccd0d9b11ffb2a364ca71fd3a63aeff057890e)
+++ test/mixtape.test.js	(revision b0ccd0d9b11ffb2a364ca71fd3a63aeff057890e)
@@ -0,0 +1,126 @@
+// De mixtape: een bandje als eigen soort (Robins idee, 21-8).
+//
+// Een playlist kon album of playlist zijn. Die keuze stond vijf keer als
+// `x === 'playlist' ? 'playlist' : 'album'` verspreid over drie bestanden, en
+// zo'n vorm valt bij een derde soort niet om -- hij SLIKT hem. Een mixtape zou
+// stilzwijgend een album worden en als Album de deur uit gaan. Die tests staan
+// hier dus voorop.
+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 { normKind, isUitgave } = await import('../src/services/PlaylistService.js');
+const PlaylistService = (await import('../src/services/PlaylistService.js')).default;
+const { afleidenUitInsluitingen, SOORTEN } = await import('../src/assets/js/shared/post-music-type.js');
+const muziek = await import('../src/services/music/index.js');
+
+const BASE = 'https://test.example';
+const site = { id: 's1', slug: 'robo', title: 'Soundfabrics' };
+
+test('mixtape is een echte soort, en onbekend blijft album', () => {
+  assert.equal(normKind('mixtape'), 'mixtape');
+  assert.equal(normKind('MIXTAPE '), 'mixtape', 'invoer uit een formulier is niet genormaliseerd');
+  assert.equal(normKind('cassette'), 'album', 'iets onbekends wordt geen nieuwe soort');
+  assert.equal(normKind(undefined), 'album');
+  assert.deepEqual(SOORTEN, ['album', 'playlist', 'mixtape']);
+});
+
+test('een bandje is geen uitgave: geen uitgavedatum, geen release-id', () => {
+  assert.equal(isUitgave('album'), true);
+  assert.equal(isUitgave('mixtape'), false);
+  assert.equal(isUitgave('playlist'), false);
+});
+
+test('een post om een mixtape IS een mixtape', () => {
+  const kindVan = (id) => (id === 'de-mixtape' ? 'mixtape' : null);
+  const r = afleidenUitInsluitingen('<p>hier</p>[[playlist:de-mixtape]]', kindVan);
+  assert.equal(r.type, 'mixtape');
+  assert.equal(r.leentMetadata, true, 'de post leent zijn titel en hoes uit');
+});
+
+test('een mixtape met losse tracks erbij blijft een mixtape', () => {
+  // De oude regel maakte van collectie + losse tracks een album. Voor een
+  // uitgave klopt dat (bonustracks), voor een bandje niet.
+  const kindVan = (id) => (id === 'tape' ? 'mixtape' : 'album');
+  const tape = afleidenUitInsluitingen('[[playlist:tape]][[track:abc]]', kindVan);
+  assert.equal(tape.type, 'mixtape');
+  assert.deepEqual(tape.bonus, ['abc']);
+
+  // En het oude gedrag voor een album blijft staan: bestaande posts mogen niet
+  // stilletjes van soort wisselen.
+  const plaat = afleidenUitInsluitingen('[[playlist:plaat]][[track:abc]]', kindVan);
+  assert.equal(plaat.type, 'album');
+});
+
+test('het Mixtape-object is een enkel object met de nummers erin', () => {
+  const rows = [
+    { id: 't1', title: 'Kant A', duration: 100, filename: 'a.mp3', mime_type: 'audio/mpeg', size: 1 },
+    { id: 't2', title: 'Kant B', duration: 44, filename: 'b.mp3', mime_type: 'audio/mpeg', size: 1 },
+  ];
+  const pl = { id: 'tape', title: 'De Mixtape', artist: 'robo', created_at: '2026-08-21T10:00:00Z' };
+  const tape = muziek.buildMixtapeObject(BASE, site, pl, rows);
+
+  // type is een STRING. Een array zou geldig AS2 zijn en toch fout: een lezer
+  // die het als tekst uitpakt (Shaer) verliest dan stil het hele object.
+  assert.equal(typeof tape.type, 'string');
+  assert.equal(tape.type, 'Mixtape');
+  assert.equal(tape.id, `${BASE}/ap/users/robo/playlists/tape`);
+
+  // Een enkel object, samengesteld uit playlistelementen.
+  assert.equal(tape.totalItems, 2);
+  assert.equal(tape.orderedItems.length, 2);
+  assert.equal(tape.orderedItems[0].type, 'Audio');
+  assert.equal(tape.orderedItems[0].id, `${BASE}/ap/users/robo/tracks/t1`);
+  assert.equal(tape.orderedItems[1].name, 'Kant B');
+
+  // De lengte van het bandje: 100 + 44.
+  assert.equal(tape.duration, 'PT144S');
+
+  // Geen uitgavevelden. Die zouden beweren dat dit een plaat is.
+  assert.equal(tape.released, undefined);
+  assert.equal(tape.musicbrainzId, undefined);
+  // En geen eigen stream: het is een omhulling, niet een gerenderd bestand.
+  assert.equal(tape.url, undefined);
+});
+
+test('een gat in de duur levert geen verzonnen totaal op', () => {
+  const rows = [
+    { id: 't1', title: 'A', duration: 100, filename: 'a.mp3', mime_type: 'audio/mpeg', size: 1 },
+    { id: 't2', title: 'B', duration: 0, filename: 'b.mp3', mime_type: 'audio/mpeg', size: 1 },
+  ];
+  const tape = muziek.buildMixtapeObject(BASE, site, { id: 'x', title: 'X' }, rows);
+  assert.equal(tape.duration, undefined, 'liever geen veld dan een som met gaten');
+});
+
+test('de collectie van een mixtape draagt zijn velden, en blijft OrderedCollection', () => {
+  const rows = [{ id: 't1', title: 'A', duration: 60, filename: 'a.mp3', mime_type: 'audio/mpeg', size: 1 }];
+  const uit = muziek.buildPlaylistCollection(BASE, site, { id: 'tape', title: 'De Mixtape', kind: 'mixtape' }, rows);
+  assert.equal(uit.type, 'OrderedCollection', 'het adres blijft een collectie voor wie hem zo ophaalt');
+  assert.equal(typeof uit.type, 'string');
+  assert.equal(uit.duration, 'PT60S', 'de mixtape-velden horen hier ook te staan');
+  // Uitgavevelden horen er NIET op: dit is geen plaat.
+  assert.equal(uit.released, undefined);
+  assert.equal(uit.musicbrainzId, undefined);
+});
+
+test('opslaan en teruglezen houdt de soort vast', () => {
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('u1', 'baas', 'b@t.nl', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)')
+    .run('s1', 'robo', 'Soundfabrics', 'u1');
+
+  const id = PlaylistService.create('s1', { title: 'De Mixtape', kind: 'mixtape', tracks: [] });
+  assert.ok(id, 'aanmaken hoort te lukken');
+  assert.equal(PlaylistService.get('s1', id).kind, 'mixtape', 'de soort mag niet terugvallen op album');
+  assert.equal(PlaylistService.list('s1').find((p) => p.id === id).kind, 'mixtape');
+
+  // En de server leidt het posttype er ook echt uit af, via de opzoeker die de
+  // database raadpleegt -- niet via de meegegeven testfunctie hierboven.
+  const r = muziek.postMusicType(`[[playlist:${id}]]`, 's1');
+  assert.equal(r.type, 'mixtape');
+});
