Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 8179fdb41a15e9de99e4eb136db707d7ff3c6781)
+++ src/services/ActivityPubService.js	(revision 060d4a512c8521a8df44895c3c233dee3ed00795)
@@ -32,4 +32,17 @@
 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
 export { AP_CONTEXT, actorId, noteId, guessMediaType };
+// De muziekkant woont in music/ (shaer-drc). Doorgeven wat hier altijd
+// vandaan kwam, zodat elke bestaande aanroep blijft werken.
+import {
+  playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
+  buildTrackAudio, buildTrackCollection, buildTrackCreate,
+  buildPlaylistCollection, listPlaylistsAP, playlistLinkTags,
+  licentieUri, channelCategory,
+} from './music/index.js';
+export {
+  playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
+  buildTrackAudio, buildTrackCollection, buildTrackCreate,
+  buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, licentieUri,
+};
 
 
@@ -245,21 +258,4 @@
 }
 
-/**
- * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
- * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
- * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
- * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
- */
-export function channelCategory(site) {
-  try {
-    // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
-    // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
-    // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
-    // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
-    // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
-    // muziekkanaal.
-    return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
-  } catch { return null; }
-}
 
 /**
@@ -969,25 +965,4 @@
 }
 
-/**
- * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
- *
- * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
- * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
- * ophaalt niet denkt dat er iets nieuws is.
- */
-export function buildTrackCreate(base, site, r, opts = {}) {
-  const audio = buildTrackAudio(base, site, r, opts);
-  const me = actorId(base, site.slug);
-  return {
-    '@context': AP_CONTEXT,
-    id: `${audio.id}#create`,
-    type: 'Create',
-    actor: me,
-    published: audio.published,
-    to: [PUBLIC],
-    cc: [`${me}/followers`],
-    object: audio,
-  };
-}
 
 /**
@@ -1087,280 +1062,4 @@
 // open deel: een eerlijke telling over wat er werkelijk in de collectie staat,
 // niet over wat wij thuis in de kast hebben.
-// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
-const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
-     t.position, t.license,
-     m.filename, m.storage_path, m.mime_type, m.size`;
-
-export function playlistOpenTracks(playlistId) {
-  return db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM playlist_tracks pt
-     JOIN audio_tracks t ON t.id = pt.track_id
-     JOIN media m ON m.id = t.media_id
-     WHERE pt.playlist_id = ? AND t.fedi_open = 1
-     ORDER BY pt.position`
-  ).all(playlistId);
-}
-
-/**
- * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
- *
- * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
- * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
- * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
- */
-export function siteOpenTracks(siteId) {
-  return db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM audio_tracks t JOIN media m ON m.id = t.media_id
-     WHERE t.site_id = ? AND t.fedi_open = 1
-     ORDER BY t.position, t.created_at, t.id`
-  ).all(siteId);
-}
-
-export function openTrack(siteId, trackId) {
-  return db.prepare(
-    `SELECT ${TRACK_KOLOMMEN}
-     FROM audio_tracks t JOIN media m ON m.id = t.media_id
-     WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
-  ).get(siteId, trackId);
-}
-
-/**
- * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
- *
- * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
- * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
- * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
- * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
- * precies zo, per stuk, in Create en Delete.
- *
- * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
- * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
- * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
- * track wordt getoond binnen een post, en een post over vijf nummers is niet de
- * pagina van dit ene nummer. Liever geen link dan een link die iets anders
- * belooft.
- */
-/**
- * Bij welke post hoort een track? (shaer-0nh)
- *
- * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
- * een van drie insluitingen in posts.content. Die relatie stond alleen in die
- * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
- * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
- *
- * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
- * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
- * scans. Nu is het er een, en de map gaat mee als optie.
- *
- * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
- * is specifieker dan via een playlist, en die weer specifieker dan via een
- * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
- * laatst heeft uitgebracht.
- */
-export function trackHostPosts(siteId) {
-  const rijen = db.prepare(`
-    SELECT tid, post_id, post_slug, rang, wanneer FROM (
-      SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
-             COALESCE(p.published_at, p.created_at) AS wanneer
-        FROM audio_tracks t
-        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
-                    AND p.content LIKE '%[[track:' || t.id || ']]%'
-       WHERE t.site_id = ? AND t.fedi_open = 1
-      UNION ALL
-      SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
-        FROM playlist_tracks pt
-        JOIN audio_tracks t ON t.id = pt.track_id
-        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
-                    AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
-       WHERE t.site_id = ? AND t.fedi_open = 1
-      UNION ALL
-      SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
-        FROM audio_tracks t
-        JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
-                    AND p.content LIKE '%[[album:' || t.album || ']]%'
-       WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
-    ) ORDER BY rang, wanneer DESC
-  `).all(siteId, siteId, siteId);
-  const uit = new Map();
-  for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
-  return uit;
-}
-
-export function buildTrackAudio(base, site, r, opts = {}) {
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  const fn = r.filename || (r.storage_path || '').split('/').pop();
-  // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
-  // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
-  // Zo doet Funkwhale het ook.
-  // De post waar dit nummer in staat. Meegegeven door de collectie (een
-  // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
-  // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
-  const post = opts.hostPosts !== undefined
-    ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
-    : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
-
-  const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
-  if (Number(r.size)) bestand.size = Number(r.size);
-  // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
-  // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
-  // de getallen hebben -- liever geen veld dan een verzonnen getal.
-  if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
-
-  const a = {
-    ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
-    id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
-    type: 'Audio',
-    name: r.title || 'Audio',
-    attributedTo: actorId(base, site.slug),
-    // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
-    // track moet zelf kunnen zeggen dat hij openbaar is.
-    to: [PUBLIC],
-    // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
-    // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
-    // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
-    // horen, en dat is wat zo'n link betekent.
-    url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
-  };
-  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
-  // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
-  // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
-  // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
-  // van te maken.
-  if (post) a.context = noteId(base, post.id);
-  if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
-  if (r.created_at) a.published = new Date(r.created_at).toISOString();
-  if (Number(r.position)) a.position = Number(r.position);
-  const lic = licentieUri(r.license);
-  if (lic) a.license = lic;
-  const art = abs(r.cover_url || opts.coverFallback || null);
-  // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
-  // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
-  if (art) {
-    const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
-    a.icon = plaat;
-    a.image = plaat;
-  }
-  return a;
-}
-
-/**
- * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
- * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
- * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
- * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
- * een lezer gelooft hem.
- */
-const LICENTIES = {
-  'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
-  'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
-  'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
-  'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
-  'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
-  'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
-};
-export function licentieUri(waarde) {
-  const s = String(waarde || '').trim();
-  if (!s) return null;
-  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
-}
-
-/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
-export function buildTrackCollection(base, site, rows) {
-  return {
-    '@context': AP_CONTEXT,
-    id: `${actorId(base, site.slug)}/tracks`,
-    type: 'OrderedCollection',
-    attributedTo: actorId(base, site.slug),
-    totalItems: (rows || []).length,
-    // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
-    orderedItems: (() => {
-      const posts = site.id ? trackHostPosts(site.id) : null;
-      return (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
-    })(),
-  };
-}
-
-// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
-// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
-// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
-// client die hem kent haalt de collectie op. Opgelost uit post.content en
-// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
-// zonder site-check zou een post van site A naar de collectie van site B
-// kunnen wijzen.
-export function playlistLinkTags(base, site, content) {
-  const out = [];
-  const seen = new Set();
-  try {
-    for (const m of (content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) {
-      if (seen.has(m[1])) continue;
-      seen.add(m[1]);
-      const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(m[1], site.id);
-      if (!pl) continue;
-      out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
-    }
-  } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
-  return out;
-}
-
-// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
-// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
-// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
-// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
-// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
-// poort staat.
-export function listPlaylistsAP(base, site, enriched) {
-  const rows = db.prepare(
-    'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
-  ).all(site.id);
-  const colId = `${actorId(base, site.slug)}/playlists`;
-  const items = rows.map((p) => {
-    const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
-    if (!enriched) return uri;
-    const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
-    delete stub['@context'];       // genest object draagt de context van zijn omhulsel
-    delete stub.orderedItems;      // stub: wie de tracks wil, haalt de collectie op
-    return stub;
-  });
-  return {
-    '@context': AP_CONTEXT,
-    id: colId,
-    type: 'OrderedCollection',
-    attributedTo: actorId(base, site.slug),
-    totalItems: items.length,
-    orderedItems: items,
-  };
-}
-
-export function buildPlaylistCollection(base, site, playlist, rows) {
-  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
-  // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
-  // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
-  // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
-  // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
-  // 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 out = {
-    '@context': AP_CONTEXT,
-    id: `${actorId(base, site.slug)}/playlists/${playlist.id}`,
-    type: 'OrderedCollection',
-    name: playlist.title,
-    attributedTo: actorId(base, site.slug),
-    totalItems: items.length,
-    orderedItems: items,
-  };
-  // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
-  const parts = [];
-  if (playlist.artist) parts.push(playlist.artist);
-  if (playlist.year) parts.push(String(playlist.year));
-  if (parts.length) out.summary = parts.join(' · ');
-  const cover = abs(playlist.cover_url || null);
-  if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
-  return out;
-}
 
 // ── followers store (lazy stmts) ──────────────────────────────────
