Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision eac32296ba3bf6a2e12ab2cf2a358f9b1814a36e)
+++ src/routes/activitypub.js	(revision 20916473b2bb8d6d0a79829066f903ad7932229f)
@@ -1142,4 +1142,26 @@
 });
 
+// De bibliotheek van deze site (shaer-0nh). Funkwhale's Audio draagt een
+// `library`, en dat is bij hen het haakje waar een UPLOAD aan komt te hangen --
+// zonder die bak blijft een binnengehaalde track daar een naam zonder geluid.
+// Gemeten op 13-8: open.audio had onze vier tracks wel, met onze eigen AP-id's,
+// maar uploads leeg en is_playable false.
+//
+// Openbaar, want alles erin is fedi_open. Er valt dus niets goed te keuren en de
+// volgerslijst blijft leeg: wie ons volgt volgt de ACTOR, niet de bak.
+router.get('/ap/users/:slug/library', (req, res) => {
+  const site = publicSite(req.params.slug);
+  if (!site) return res.status(404).end();
+  AP.sendAP(res, AP.buildLibrary(baseUrl(req), site, AP.siteOpenTracks(site.id)));
+});
+
+// De volgerscollectie die hun docs als vereist noemen. Leeg en eerlijk: er is
+// geen goedkeuringspad omdat de bibliotheek openbaar is.
+router.get('/ap/users/:slug/library/followers', (req, res) => {
+  const site = publicSite(req.params.slug);
+  if (!site) return res.status(404).end();
+  AP.sendAP(res, AP.pagedCollection(`${AP.libraryId(baseUrl(req), site)}/followers`, []));
+});
+
 // Eén track, los op te halen. Een gesloten track is AFWEZIG, niet leeg: 404,
 // dezelfde regel als in de collectie, zodat het bestaan van een gated nummer
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision eac32296ba3bf6a2e12ab2cf2a358f9b1814a36e)
+++ src/services/ActivityPubService.js	(revision 20916473b2bb8d6d0a79829066f903ad7932229f)
@@ -39,4 +39,5 @@
   buildPlaylistCollection, listPlaylistsAP, playlistLinkTags,
   buildPostTrackCollection, uitgavePost,
+  buildLibrary, libraryId,
   licentieUri, channelCategory,
 } from './music/index.js';
@@ -46,4 +47,5 @@
   buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, licentieUri,
   buildPostTrackCollection, uitgavePost,
+  buildLibrary, libraryId,
 };
 
@@ -6809,4 +6811,5 @@
   buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
   buildPostTrackCollection, uitgavePost,
+  buildLibrary, libraryId,
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
   feedCursor, feedChangesSince, waitForFeedChange,
Index: src/services/ap-core.js
===================================================================
--- src/services/ap-core.js	(revision eac32296ba3bf6a2e12ab2cf2a358f9b1814a36e)
+++ src/services/ap-core.js	(revision 20916473b2bb8d6d0a79829066f903ad7932229f)
@@ -57,4 +57,20 @@
     // zou een verhuizing kunnen laten mislukken.
     sameAs: { '@id': 'schema:sameAs', '@type': '@id' },
+    // ── De ENIGE term die we uit Funkwhale's eigen vocabulaire overnemen ──
+    //
+    // Waarom deze wel en track/ArtistCredit niet (shaer-0nh): die twee vragen
+    // ENTITEITEN waar wij tekst hebben -- een artiest en een album zijn bij ons
+    // kolommen, en er een id voor verzinnen zou beloven wat we niet kunnen
+    // waarmaken. Een Library is iets anders: het is precies wat we AL hebben,
+    // een verzameling van onze eigen open tracks met een echte telling.
+    //
+    // En hij doet iets. Gemeten op 13-8: open.audio heeft onze vier tracks
+    // binnengehaald via hun AP-id, met artist_credit dat het zelf uit onze
+    // attributedTo afleidde -- maar uploads is LEEG en is_playable false. Bij
+    // Funkwhale hangt een upload aan een library; zonder library is er geen bak
+    // om het bestand in te hangen, en blijft een track een naam zonder geluid.
+    fw: 'https://funkwhale.audio/ns#',
+    Library: 'fw:Library',
+    library: { '@id': 'fw:library', '@type': '@id' },
     position: 'schema:position',
     bitrate: 'schema:bitrate',
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision eac32296ba3bf6a2e12ab2cf2a358f9b1814a36e)
+++ src/services/music/index.js	(revision 20916473b2bb8d6d0a79829066f903ad7932229f)
@@ -168,4 +168,8 @@
     url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
   };
+  // De bak waar dit bestand in hangt (shaer-0nh). Voor Funkwhale is dit het
+  // haakje waaraan een upload komt te zitten; zonder dit veld blijft een track
+  // daar een naam zonder geluid.
+  a.library = libraryId(base, site);
   if (r.artist) a.summary = r.artist;              // artiest als summary: kaal AS2, geen eigen vocab
   // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
@@ -211,4 +215,48 @@
   if (/^https?:\/\//i.test(s)) return safeUrl(s);   // iemand vulde al een URI in
   return LICENTIES[s.toLowerCase()] || null;        // "Alle rechten voorbehouden" heeft er geen
+}
+
+/** Het AS2-id van de bibliotheek van een site. */
+export function libraryId(base, site) {
+  return `${actorId(base, site.slug)}/library`;
+}
+
+/**
+ * De site als Funkwhale-LIBRARY (skelet).
+ *
+ * WAAROM DIT GEEN DIALECT IS ZOALS track EN ArtistCredit DAT WEL ZIJN. Die twee
+ * vragen entiteiten waar wij tekst hebben; hiervoor hoeven we niets te
+ * verzinnen. Een library is precies wat er al staat: onze open tracks, met een
+ * echte telling en een echt id.
+ *
+ * WAAROM HET NODIG IS, gemeten op 13-8. open.audio heeft onze vier tracks
+ * binnengehaald langs de AP-weg -- met ONZE track-id's, en met een artist_credit
+ * dat Funkwhale zelf uit onze attributedTo afleidde. Maar `uploads` is leeg en
+ * `is_playable` false. Bij hen hangt een upload aan een library; zonder library
+ * is er geen bak om het bestand in te hangen. Het audiobestand zelf is wel
+ * gewoon op te halen (200, audio/mpeg, ook anoniem) -- ze hebben het niet
+ * geprobeerd.
+ *
+ * SKELET, en dat woord is letterlijk bedoeld. Dit is de vorm uit hun docs:
+ * type, id, name, followers, totalItems, first, last, plus attributedTo en
+ * summary. Wat er NIET is: de volg-afhandeling. Onze bibliotheek is openbaar --
+ * elke track erin heeft fedi_open -- dus er valt niets goed te keuren. Komt er
+ * ooit een besloten variant, dan hoort daar het Follow/Accept-werk bij.
+ */
+export function buildLibrary(base, site, rows) {
+  const id = libraryId(base, site);
+  const hostPosts = site.id ? trackHostPosts(site.id) : null;
+  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts }));
+  return pagedCollection(id, items, {
+    extra: {
+      type: 'Library',
+      name: site.title || site.slug,
+      attributedTo: actorId(base, site.slug),
+      // Vereist volgens hun docs. Openbaar, dus de telling is eerlijk en de
+      // lijst blijft leeg -- wie ons volgt volgt de ACTOR, niet de bak.
+      followers: `${id}/followers`,
+      ...(site.description ? { summary: String(site.description).slice(0, 500) } : {}),
+    },
+  });
 }
 
Index: test/ap-library.test.js
===================================================================
--- test/ap-library.test.js	(revision 20916473b2bb8d6d0a79829066f903ad7932229f)
+++ test/ap-library.test.js	(revision 20916473b2bb8d6d0a79829066f903ad7932229f)
@@ -0,0 +1,76 @@
+// De site als Funkwhale-Library, skelet (shaer-0nh).
+//
+// AANLEIDING, gemeten op 13-8 tegen open.audio: onze vier tracks waren daar
+// binnengehaald langs de AP-weg -- met ONZE track-id's als fid, en met een
+// artist_credit dat Funkwhale zelf uit onze attributedTo had afgeleid. Maar
+// `uploads` was leeg en `is_playable` false. Bij hen hangt een upload aan een
+// library; zonder die bak blijft een track een naam zonder geluid. Het
+// audiobestand zelf is gewoon anoniem op te halen -- ze hadden het niet
+// geprobeerd.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://ons.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+const BASE = 'https://ons.test';
+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, description, owner_id) VALUES (?,?,?,?,?)')
+  .run('s1', 'band', 'De Band', 'Onze plaatjes', 'u1');
+const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+
+const insM = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,?)');
+const insT = db.prepare('INSERT INTO audio_tracks (id, site_id, title, duration, media_id, fedi_open) VALUES (?,?,?,?,?,?)');
+insM.run('m1', 's1', 'open.mp3', 'a/open.mp3', 'audio/mpeg', 4210688);
+insM.run('m2', 's1', 'dicht.mp3', 'a/dicht.mp3', 'audio/mpeg', 999);
+insT.run('t1', 's1', 'Het open nummer', 203, 'm1', 1);
+insT.run('t2', 's1', 'Achter de poort', 100, 'm2', 0);
+
+const LIB = `${BASE}/ap/users/band/library`;
+
+test('de bibliotheek heeft de vorm die hun docs voorschrijven', () => {
+  const lib = AP.buildLibrary(BASE, site, AP.siteOpenTracks('s1'));
+  assert.equal(lib.type, 'Library', 'niet OrderedCollection -- daar zoeken ze op');
+  assert.equal(lib.id, LIB);
+  assert.equal(lib.name, 'De Band');
+  assert.equal(lib.attributedTo, `${BASE}/ap/users/band`);
+  assert.equal(lib.followers, `${LIB}/followers`, 'vereist volgens hun docs');
+  assert.equal(lib.first, `${LIB}?page=1`);
+  assert.equal(lib.last, `${LIB}?page=1`);
+  assert.equal(lib.totalItems, 1);
+  assert.equal(lib.summary, 'Onze plaatjes');
+});
+
+test('een gesloten track hoort er NIET in -- dicht is dicht', () => {
+  // De bibliotheek is openbaar; dat kan alleen als er niets in zit dat het
+  // niet is. Zou een gated track hier binnenglippen, dan zou de openbaarheid
+  // van de bak zijn poort overrulen.
+  const lib = AP.buildLibrary(BASE, site, AP.siteOpenTracks('s1'));
+  assert.deepEqual(lib.orderedItems.map((a) => a.name), ['Het open nummer']);
+});
+
+test('elke Audio wijst naar de bibliotheek', () => {
+  // Dit is het veld waar het om begonnen was: het haakje waar een upload aan komt.
+  const lib = AP.buildLibrary(BASE, site, AP.siteOpenTracks('s1'));
+  for (const a of lib.orderedItems) assert.equal(a.library, LIB);
+  // Ook als de track ergens anders vandaan komt -- de losse ophaal, de
+  // trackcollectie, de playlist. Anders hangt het er maar op een plek.
+  const los = AP.buildTrackAudio(BASE, site, AP.openTrack('s1', 't1'), { standalone: true });
+  assert.equal(los.library, LIB);
+});
+
+test('de term is gedeclareerd, anders laat een strikte lezer hem vallen', async () => {
+  const core = await import('../src/services/ap-core.js');
+  const term = core.AP_CONTEXT.find((x) => x && typeof x === 'object' && x.library);
+  assert.ok(term, 'library staat in de context');
+  assert.equal(term.library['@id'], 'fw:library');
+  assert.equal(term.library['@type'], '@id', 'het is een URI, geen tekst');
+  assert.equal(term.Library, 'fw:Library');
+  assert.equal(term.fw, 'https://funkwhale.audio/ns#');
+});
