Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision d68ee3aebf96df5e90d156f7a3d01115680ff3c7)
+++ src/services/music/index.js	(revision aebde2b68e0584c0f2f94fe588ec668858e27513)
@@ -134,4 +134,33 @@
   for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
   return uit;
+}
+
+/**
+ * De artiest-credit, gedeeld door track en album (shaer-3f8a / shaer-756s).
+ *
+ * De ENTITEIT is de site-actor: een echt, opvraagbaar adres. De credittekst --
+ * de artiestkolom van de track of van de uitgave -- gaat naar `credit`, want
+ * daar verwacht hun model hem. Er een id per artiestnaam van maken zou
+ * identiteit uit een string zijn, en dat is de fout die we bij albums juist
+ * vermijden.
+ *
+ * Eén functie voor beide, zodat een track en het album waar hij op staat nooit
+ * een verschillende artiest kunnen krijgen door twee keer hetzelfde te bouwen.
+ */
+function artistCredit(base, site, creditTekst, wanneer) {
+  const artiest = {
+    type: 'Artist',
+    id: actorId(base, site.slug),
+    name: site.title || site.slug,
+    published: site.created_at ? new Date(site.created_at).toISOString() : wanneer,
+  };
+  if (isMbid(site.mb_artist_id)) artiest.musicbrainzId = String(site.mb_artist_id).trim().toLowerCase();
+  return [{
+    type: 'ArtistCredit',
+    id: `${actorId(base, site.slug)}#artist-credit`,
+    published: artiest.published,
+    artist: artiest,
+    ...(creditTekst ? { credit: creditTekst } : {}),
+  }];
 }
 
@@ -213,11 +242,4 @@
   const wanneer = r.created_at ? new Date(r.created_at).toISOString()
     : (site.created_at ? new Date(site.created_at).toISOString() : new Date(0).toISOString());
-  const artiest = {
-    type: 'Artist',
-    id: actorId(base, site.slug),
-    name: site.title || site.slug,
-    published: site.created_at ? new Date(site.created_at).toISOString() : wanneer,
-  };
-  if (isMbid(site.mb_artist_id)) artiest.musicbrainzId = String(site.mb_artist_id).trim().toLowerCase();
 
   a.track = {
@@ -227,12 +249,17 @@
     published: wanneer,
     ...(Number(r.position) ? { position: Number(r.position) } : {}),
-    artist_credit: [{
-      type: 'ArtistCredit',
-      id: `${a.id}#artist-credit`,
-      published: wanneer,
-      artist: artiest,
-      ...(r.artist ? { credit: r.artist } : {}),
-    }],
+    artist_credit: artistCredit(base, site, r.artist, wanneer),
   };
+  // De uitgave waar dit nummer op staat, INGESLOTEN (shaer-756s, stap 2).
+  // `albums` mag expliciet null zijn: dan is er niets op te zoeken.
+  const uitgave = opts.albums !== undefined
+    ? (opts.albums && opts.albums.get(r.id)) || null
+    : ((site.id && trackAlbums(site.id).get(r.id)) || null);
+  if (uitgave) {
+    a.track.album = buildAlbumObject(base, site, uitgave);
+    // Ook op het Audio-object zelf, als URI. Funkwhale 2.0 en Emissary doen dat
+    // allebei, en het scheelt een lezer het uitpakken van de track.
+    a.album = a.track.album.id;
+  }
   if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
   if (r.created_at) a.published = new Date(r.created_at).toISOString();
@@ -303,5 +330,6 @@
   const id = libraryId(base, site);
   const hostPosts = site.id ? trackHostPosts(site.id) : null;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts }));
+  const albums = site.id ? trackAlbums(site.id) : null;
+  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts, albums }));
   return pagedCollection(id, items, {
     page,
@@ -337,5 +365,6 @@
   // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
   const posts = site.id ? trackHostPosts(site.id) : null;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
+  const albums = site.id ? trackAlbums(site.id) : null;
+  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts, albums }));
   return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
 }
@@ -399,4 +428,64 @@
 }
 
+/**
+ * Bij welke UITGAVE hoort een track? (shaer-756s, stap 2)
+ *
+ * Alleen playlists met kind='album' tellen: een mixtape is geen uitgave, en dat
+ * onderscheid is precies wat de keuze album/playlist betekent. Zit een track in
+ * twee albums, dan wint de oudste -- willekeurig maar STABIEL, en dat is wat
+ * telt: een id dat per ophaalactie verspringt is erger dan een id dat niet de
+ * mooiste keuze is.
+ *
+ * Eén zoekopdracht voor alle rijen samen, zoals trackHostPosts. Per track
+ * vragen wordt bij tweehonderd nummers tweehonderd zoekopdrachten.
+ */
+export function trackAlbums(siteId) {
+  const rijen = db.prepare(`
+    SELECT pt.track_id AS tid, p.id, p.title, p.artist, p.year, p.cover_url,
+           p.release_date, p.mb_release_id, p.created_at
+      FROM playlist_tracks pt
+      JOIN playlists p ON p.id = pt.playlist_id
+     WHERE p.site_id = ? AND p.kind = 'album'
+     ORDER BY p.created_at, p.id
+  `).all(siteId);
+  const uit = new Map();
+  for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, r);
+  return uit;
+}
+
+/**
+ * Een uitgave als `fw:Album`.
+ *
+ * INGESLOTEN EN NIET ALS URI, en dat is het hele punt van deze stap. Funkwhale's
+ * TrackSerializer heeft `album = AlbumSerializer()` -- een object met name,
+ * published en een eigen artist_credit. Een kale URI expandeert naar een knoop
+ * met alleen een @id en valt daar dus af. Emissary stuurt precies zo'n kale URI,
+ * en dat is waarom hun tracks bij Funkwhale net zo goed stranden.
+ *
+ * Het `id` is de bestaande playlist-collectie: dereferenceerbaar, en het is
+ * werkelijk hetzelfde ding. We verzinnen geen tweede adres voor iets dat er al
+ * een heeft.
+ */
+export function buildAlbumObject(base, site, pl) {
+  if (!pl) return null;
+  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
+  const wanneer = pl.created_at ? new Date(pl.created_at).toISOString() : new Date(0).toISOString();
+  const album = {
+    type: 'Album',
+    id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
+    name: pl.title,
+    published: wanneer,
+    attributedTo: actorId(base, site.slug),
+    artist_credit: artistCredit(base, site, pl.artist, wanneer),
+  };
+  // `released` alleen als er een ECHTE datum is. `year` vult hem niet aan: een
+  // jaartal is geen dag, en dat is de reden dat release_date bestaat.
+  if (pl.release_date) album.released = pl.release_date;
+  if (pl.mb_release_id) album.musicbrainzId = pl.mb_release_id;
+  const hoes = abs(pl.cover_url || null);
+  if (hoes) album.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
+  return album;
+}
+
 export function buildPlaylistCollection(base, site, playlist, rows) {
   const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
@@ -407,5 +496,6 @@
   // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
   const hostPosts = site.id ? trackHostPosts(site.id) : null;
-  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
+  const albums = site.id ? trackAlbums(site.id) : null;
+  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts, albums }));
   const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
     extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
@@ -418,4 +508,23 @@
   const cover = abs(playlist.cover_url || null);
   if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
+
+  // Is dit een UITGAVE, dan draagt deze collectie ook de albumvelden
+  // (shaer-756s, stap 2): het is het adres waar track.album naar wijst, en dan
+  // hoort hier hetzelfde te staan als in het ingesloten object.
+  //
+  // `type` blijft OrderedCollection, EN BLIJFT EEN STRING. Er stond hier even
+  // ['OrderedCollection', 'Album'] -- geldig AS2, en het is ook werkelijk
+  // allebei -- maar een bestaande test viel erover, en die test had gelijk: een
+  // lezer die `type` als tekst uitpakt (Shaer doet dat) verliest dan in stilte
+  // de hele playlist. Het kost ons niets, want hun AlbumSerializer declareert
+  // geen type-veld en valideert het dus niet: haalt Funkwhale dit adres op als
+  // 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 album = buildAlbumObject(base, site, playlist);
+    for (const veld of ['published', 'released', 'musicbrainzId', 'artist_credit', 'image']) {
+      if (album[veld] !== undefined) out[veld] = album[veld];
+    }
+  }
   return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id));
 }
Index: test/ap-fw-track.test.js
===================================================================
--- test/ap-fw-track.test.js	(revision d68ee3aebf96df5e90d156f7a3d01115680ff3c7)
+++ test/ap-fw-track.test.js	(revision aebde2b68e0584c0f2f94fe588ec668858e27513)
@@ -55,7 +55,14 @@
 });
 
-test('geen album zolang het bij ons geen object is', () => {
-  // Een tekstkolom als URI meesturen is een adres beloven dat niet bestaat.
-  assert.equal(audio('t1').track.album, undefined);
+test('een track zonder uitgave draagt GEEN album', () => {
+  // Dit stond er in stap 1 als "nog geen album, want we hebben er geen object
+  // voor". Sinds stap 2 hebben we dat wel -- de album-playlist -- en is de regel
+  // scherper: geen uitgave, geen veld. De tekstkolom `album` op de track blijft
+  // buiten de draad, want dat is een label en geen adres.
+  db.prepare("INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES ('m9','s1','los.mp3','a/los.mp3','audio/mpeg',10)").run();
+  db.prepare("INSERT INTO audio_tracks (id, site_id, title, album, media_id, fedi_open) VALUES ('t9','s1','Losse track','Een Albumnaam','m9',1)").run();
+  const a = audio('t9');
+  assert.equal(a.track.album, undefined, 'een albumNAAM is geen album');
+  assert.equal(a.album, undefined);
 });
 
@@ -136,2 +143,63 @@
   db.prepare("UPDATE sites SET mb_artist_id = NULL WHERE id = 's1'").run();
 });
+
+// ── het album ingesloten (shaer-756s, stap 2) ────────────────────────
+//
+// Hun TrackSerializer heeft `album = AlbumSerializer()`: een OBJECT met name,
+// published en een eigen artist_credit. Een kale URI expandeert naar een knoop
+// met alleen een @id en valt daar af -- dat is precies waarom Emissary's tracks
+// bij Funkwhale ook stranden.
+
+db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at)
+            VALUES ('de-plaat','s1','De Plaat','De Band',2024,'/media/hoes.jpg','album','2024-03-15','7c5a9b2e-1111-4222-8333-944455556666','2026-01-01T00:00:00Z')`).run();
+db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('de-plaat','t1',1)").run();
+// t2 zit in een MIXTAPE en hoort dus geen album te krijgen.
+db.prepare("INSERT INTO playlists (id, site_id, title, kind) VALUES ('de-mix','s1','De Mix','playlist')").run();
+db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('de-mix','t2',1)").run();
+
+test('een track op een uitgave draagt het album als OBJECT', () => {
+  const a = audio('t1');
+  const al = a.track.album;
+  assert.ok(al && typeof al === 'object', 'album is geen object');
+  assert.equal(al.type, 'Album');
+  assert.equal(al.id, `${BASE}/ap/users/band/playlists/de-plaat`, 'het id is de bestaande collectie');
+  assert.equal(al.name, 'De Plaat');
+  assert.ok(al.published, 'MusicEntitySerializer eist published');
+  assert.equal(al.released, '2024-03-15');
+  assert.equal(al.musicbrainzId, '7c5a9b2e-1111-4222-8333-944455556666');
+  assert.ok(Array.isArray(al.artist_credit) && al.artist_credit.length >= 1, 'album zonder artist_credit');
+  assert.equal(al.image.type, 'Image');
+  // Ook als URI op de Audio zelf -- Funkwhale 2.0 en Emissary doen dat allebei.
+  assert.equal(a.album, al.id);
+});
+
+test('een track in een MIXTAPE krijgt geen album', () => {
+  // Een afspeellijst is geen uitgave. Zou hij hier een album krijgen, dan was
+  // het onderscheid album/playlist decoratie.
+  const a = audio('t2');
+  assert.equal(a.track.album, undefined);
+  assert.equal(a.album, undefined);
+});
+
+test('track en album delen dezelfde artiest', () => {
+  // Twee keer los bouwen is hoe ze uit elkaar gaan lopen; er is een functie.
+  const a = audio('t1');
+  assert.deepEqual(a.track.artist_credit[0].artist, a.track.album.artist_credit[0].artist);
+});
+
+test('de playlist-collectie draagt de albumvelden, met type als STRING', () => {
+  const pl = db.prepare("SELECT * FROM playlists WHERE id = 'de-plaat'").get();
+  const col = AP.buildPlaylistCollection(BASE, site, pl, AP.playlistOpenTracks('de-plaat'));
+  assert.equal(typeof col.type, 'string', 'een array breekt lezers die type als tekst uitpakken');
+  assert.equal(col.type, 'OrderedCollection');
+  assert.equal(col.released, '2024-03-15');
+  assert.equal(col.musicbrainzId, '7c5a9b2e-1111-4222-8333-944455556666');
+  assert.ok(col.artist_credit);
+});
+
+test('een mixtape-collectie krijgt GEEN albumvelden', () => {
+  const pl = db.prepare("SELECT * FROM playlists WHERE id = 'de-mix'").get();
+  const col = AP.buildPlaylistCollection(BASE, site, pl, AP.playlistOpenTracks('de-mix'));
+  assert.equal(col.released, undefined);
+  assert.equal(col.artist_credit, undefined);
+});
