Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision 20916473b2bb8d6d0a79829066f903ad7932229f)
+++ src/services/music/index.js	(revision 732272a320793693be207b1b67af729b1feca30f)
@@ -16,4 +16,7 @@
 import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection } from '../ap-core.js';
 import { afleidenUitInsluitingen, ingeslotenPlaylists } 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.
+export * as luisteraars from './luisteraars.js';
 
 // m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
Index: src/services/music/luisteraars.js
===================================================================
--- src/services/music/luisteraars.js	(revision 732272a320793693be207b1b67af729b1feca30f)
+++ src/services/music/luisteraars.js	(revision 732272a320793693be207b1b67af729b1feca30f)
@@ -0,0 +1,73 @@
+/**
+ * Luisteraars: wie de BIBLIOTHEEK volgt (shaer-0nh).
+ *
+ * Een aparte soort volger. Ze hangen aan `/ap/users/<slug>/library` en niet aan
+ * de actor, en dat verschil is de hele bedoeling: een luisteraar krijgt de
+ * muziek en NIET de gewone posts. Iemand die zich abonneert op een
+ * platenkast heeft niet gevraagd om de Krant.
+ *
+ * WAAROM EEN EIGEN TABEL EN GEEN VLAG op ap_followers: zolang ze ergens anders
+ * staan kan een bezorging ze niet per ongeluk meenemen. Een vlag die iemand
+ * vergeet te filteren doet dat wel, en dan is de fout stil -- de posts komen
+ * gewoon aan bij mensen die er niet om vroegen, en niemand ziet het aan onze
+ * kant. Dit is dezelfde afweging als bij de wachtrijen: de vorm moet de fout
+ * onmogelijk maken, niet alleen onwaarschijnlijk.
+ */
+import db from '../../config/database.js';
+
+const stmt = (sql) => db.prepare(sql);
+
+/** Erbij, of bijwerken als hij er al was. Volgen is idempotent. */
+export function voegToe(slug, { actorUri, inbox, sharedInbox, name, handle, icon }) {
+  if (!slug || !actorUri) return false;
+  try {
+    stmt(`INSERT INTO ap_library_followers (slug, actor_uri, inbox, shared_inbox, name, handle, icon)
+          VALUES (?,?,?,?,?,?,?)
+          ON CONFLICT (slug, actor_uri) DO UPDATE SET
+            inbox = excluded.inbox, shared_inbox = excluded.shared_inbox,
+            name = excluded.name, handle = excluded.handle, icon = excluded.icon`)
+      .run(slug, actorUri, inbox || null, sharedInbox || null, name || null, handle || null, icon || null);
+    return true;
+  } catch { return false; }
+}
+
+/** Weg. Een Undo(Follow) hoort meteen te werken, niet pas na een opruiming. */
+export function verwijder(slug, actorUri) {
+  try { return stmt('DELETE FROM ap_library_followers WHERE slug = ? AND actor_uri = ?').run(slug, actorUri).changes > 0; }
+  catch { return false; }
+}
+
+/** Voor het beheerscherm. */
+export function lijst(slug) {
+  try {
+    return stmt(`SELECT actor_uri, inbox, shared_inbox, name, handle, icon, created_at, last_delivery_at, last_error_at
+                 FROM ap_library_followers WHERE slug = ? ORDER BY created_at DESC`).all(slug);
+  } catch { return []; }
+}
+
+export function telling(slug) {
+  try { return stmt('SELECT COUNT(*) n FROM ap_library_followers WHERE slug = ?').get(slug).n; }
+  catch { return 0; }
+}
+
+/** Volgt deze actor onze bibliotheek al? */
+export function isLuisteraar(slug, actorUri) {
+  try { return !!stmt('SELECT 1 FROM ap_library_followers WHERE slug = ? AND actor_uri = ?').get(slug, actorUri); }
+  catch { return false; }
+}
+
+/**
+ * De inboxen om muziek naartoe te sturen, ontdubbeld op gedeelde inbox.
+ * Nog niemand gebruikt dit -- de bezorging is de volgende stap -- maar het hoort
+ * bij de opslag en niet bij de aanroeper.
+ */
+export function inboxen(slug) {
+  const uit = new Map();
+  for (const r of lijst(slug)) {
+    const adres = r.shared_inbox || r.inbox;
+    if (adres && !uit.has(adres)) uit.set(adres, r.actor_uri);
+  }
+  return [...uit.keys()];
+}
+
+export default { voegToe, verwijder, lijst, telling, isLuisteraar, inboxen };
