Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision 19e28cedce7ba0de89244415d0c1f285866cd75a)
+++ src/routes/admin-audio.js	(revision ca260d13a940f7563a540fad1f1945ef28e9160c)
@@ -24,4 +24,6 @@
 import { mediaDir } from '../config/paths.js';
 import * as ActivityPubService from '../services/ActivityPubService.js';
+import { postsEmbeddingTrack } from '../services/music/index.js';
+import { t as i18nT, resolveLang } from '../services/i18n.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -96,5 +98,6 @@
   const rows = db.prepare(`
     SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
-           t.position, t.created_at, t.downloadable, m.filename, m.size, m.mime_type
+           t.position, t.created_at, t.downloadable, t.fedi_open, t.media_id,
+           m.filename, m.size, m.mime_type
     FROM audio_tracks t
     LEFT JOIN media m ON m.id = t.media_id
@@ -290,4 +293,60 @@
 });
 
+/**
+ * Een track openbaar op de fediverse zetten, vanuit de mediamanager (Robin,
+ * 30-9: "Fedi open audio ook per track aan kunnen zetten").
+ *
+ * Tot nu toe kon dat alleen per POST, met het vinkje in de editor, dat elke
+ * track in die post opende. De vlag zelf was altijd al per track
+ * (audio_tracks.fedi_open): hij opent het BESTAND en maakt er een echte
+ * Audio-bijlage van. Dit is dezelfde vlag, met een eigen knop.
+ *
+ * EENRICHTINGS, net als in de editor. Een bestand dat al gefedereerd is, staat
+ * op andere servers; het hier weer afschermen zou schijnveiligheid zijn. Er is
+ * dus geen weg terug, en die bouwen we ook niet.
+ *
+ * EN DE POSTS GAAN OPNIEUW DE DEUR UIT. Het vinkje in de editor werkt zichtbaar
+ * omdat het opslaan van de post een Update stuurt, met de audio erin. Een knop
+ * die alleen de vlag zet, laat elke post die al verstuurd is zonder speler
+ * staan tot iemand hem bewerkt. Dus hier dezelfde Update, voor elke
+ * gepubliceerde post waar de track in staat -- rechtstreeks, via een playlist of
+ * via zijn album. De bibliotheek (Funkwhale) hoeft niets: die wordt opgehaald,
+ * en een open track staat er meteen in.
+ *
+ * Alleen een track met een eigen BESTAND: een track die alleen een link is heeft
+ * niets om te openen.
+ *
+ * `deliver` is er voor de toetsen; de route gebruikt de echte deliverUpdate.
+ */
+export function openTrackOnFediverse(site, trackId, { deliver = (s, p) => ActivityPubService.deliverUpdate(s, p) } = {}) {
+  const t = db.prepare('SELECT id, media_id, fedi_open FROM audio_tracks WHERE id = ? AND site_id = ?').get(trackId, site.id);
+  if (!t) return { ok: false, reason: 'not_found' };
+  if (!t.media_id) return { ok: false, reason: 'no_file' };
+  if (t.fedi_open) return { ok: true, changed: false, updated: 0 };
+  db.prepare('UPDATE audio_tracks SET fedi_open = 1 WHERE id = ? AND site_id = ?').run(trackId, site.id);
+  const postIds = postsEmbeddingTrack(site.id, trackId);
+  for (const id of postIds) {
+    const post = db.prepare('SELECT * FROM posts WHERE id = ? AND site_id = ?').get(id, site.id);
+    if (!post) continue;
+    // Op de achtergrond: een trage volger houdt de knop niet vast.
+    Promise.resolve()
+      .then(() => deliver(site, post))
+      .catch((e) => console.warn('[admin-audio] Update na openen mislukt:', post.id, e.message));
+  }
+  return { ok: true, changed: true, updated: postIds.length };
+}
+
+router.post('/:id/fedi-open', requireGod, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site required');
+  const lang = resolveLang(req, { userLang: req.session?.user?.lang });
+  const r = openTrackOnFediverse(site, req.params.id);
+  if (!r.ok) {
+    const msg = r.reason === 'no_file' ? i18nT(lang, 'aaud.fedi_no_file') : 'Not found';
+    return res.redirect('/admin/audio?error=' + encodeURIComponent(msg));
+  }
+  res.redirect('/admin/audio?success=' + encodeURIComponent(i18nT(lang, 'aaud.fedi_opened', { n: r.updated })));
+});
+
 router.post('/:id/delete', requireGod, (req, res) => {
   const site = res.locals.site;
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 19e28cedce7ba0de89244415d0c1f285866cd75a)
+++ src/services/i18n.js	(revision ca260d13a940f7563a540fad1f1945ef28e9160c)
@@ -400,5 +400,5 @@
     'aaud.delete': 'Verwijderen',
     'aaud.delete_confirm': 'Track verwijderen?',
-    'aaud.dl_on': 'Download-voor-email staat AAN — klik om uit te zetten',
+    'aaud.fedi_open_btn': 'Openbaar delen op de fediverse', 'aaud.fedi_open_on': 'Openbaar gedeeld op de fediverse. Dat is permanent.', 'aaud.fedi_open_confirm': 'Deze track openbaar delen op de fediverse? Hij wordt speelbaar in apps en het bestand is te downloaden. Posts met deze track gaan opnieuw naar je volgers. Dit is permanent en niet terug te draaien.', 'aaud.fedi_opened': 'Openbaar gedeeld op de fediverse. Posts opnieuw verstuurd: {n}.', 'aaud.fedi_no_file': 'Deze track heeft geen eigen bestand, alleen een link. Er valt niets te openen.', 'aaud.dl_on': 'Download-voor-email staat AAN — klik om uit te zetten',
     'aaud.dl_off': 'Download-voor-email staat uit — klik om aan te zetten',
     'aaud.fedi_on': 'Op de fediverse gedeeld (speelt overal inline, bestand downloadbaar) — klik om uit te zetten',
@@ -1410,5 +1410,5 @@
     'aaud.delete': 'Delete',
     'aaud.delete_confirm': 'Delete track?',
-    'aaud.dl_on': 'Download-for-email is ON — click to turn off',
+    'aaud.fedi_open_btn': 'Share openly on the fediverse', 'aaud.fedi_open_on': 'Shared openly on the fediverse. This is permanent.', 'aaud.fedi_open_confirm': 'Share this track openly on the fediverse? It becomes playable in apps and the file can be downloaded. Posts with this track are sent to your followers again. This is permanent and cannot be undone.', 'aaud.fedi_opened': 'Shared openly on the fediverse. Posts sent again: {n}.', 'aaud.fedi_no_file': 'This track has no file of its own, only a link. There is nothing to open.', 'aaud.dl_on': 'Download-for-email is ON — click to turn off',
     'aaud.dl_off': 'Download-for-email is off — click to turn on',
     'aaud.fedi_on': 'Shared on the fediverse (plays inline everywhere, file downloadable) — click to turn off',
@@ -2419,5 +2419,5 @@
     'aaud.delete': 'Löschen',
     'aaud.delete_confirm': 'Track löschen?',
-    'aaud.dl_on': 'Download-für-E-Mail ist AN — zum Ausschalten klicken',
+    'aaud.fedi_open_btn': 'Offen im Fediverse teilen', 'aaud.fedi_open_on': 'Offen im Fediverse geteilt. Das ist dauerhaft.', 'aaud.fedi_open_confirm': 'Diesen Track offen im Fediverse teilen? Er wird in Apps abspielbar und die Datei kann heruntergeladen werden. Beiträge mit diesem Track werden erneut an deine Follower gesendet. Das ist dauerhaft und kann nicht rückgängig gemacht werden.', 'aaud.fedi_opened': 'Offen im Fediverse geteilt. Erneut gesendete Beiträge: {n}.', 'aaud.fedi_no_file': 'Dieser Track hat keine eigene Datei, nur einen Link. Es gibt nichts zu öffnen.', 'aaud.dl_on': 'Download-für-E-Mail ist AN — zum Ausschalten klicken',
     'aaud.dl_off': 'Download-für-E-Mail ist aus — zum Einschalten klicken',
     'aaud.fedi_on': 'Im Fediverse geteilt (spielt überall inline, Datei herunterladbar) — zum Ausschalten klicken',
Index: src/services/music/index.js
===================================================================
--- src/services/music/index.js	(revision 19e28cedce7ba0de89244415d0c1f285866cd75a)
+++ src/services/music/index.js	(revision ca260d13a940f7563a540fad1f1945ef28e9160c)
@@ -134,4 +134,38 @@
   for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
   return uit;
+}
+
+/**
+ * Elke GEPUBLICEERDE post waar deze track in staat, op welke manier ook:
+ * rechtstreeks ([[track:id]]), via een playlist of via zijn albumnaam.
+ *
+ * Voor iets anders dan trackHostPosts, dat per track EEN post kiest voor de
+ * bibliotheek. Opent de mediamanager een track (shaer, 30-9), dan moeten ALLE
+ * posts met die track opnieuw de deur uit, anders krijgt een volger de speler
+ * pas bij de volgende bewerking van die post.
+ *
+ * Alleen posts van deze site: playlists.id is een globale sleutel, en het
+ * filter hoort op de post en de track, niet op de playlist (zie de
+ * tenancy-toets bij setAudioFediOpen).
+ */
+export function postsEmbeddingTrack(siteId, trackId) {
+  const t = db.prepare('SELECT id, album FROM audio_tracks WHERE id = ? AND site_id = ?').get(trackId, siteId);
+  if (!t) return [];
+  const rows = db.prepare(`
+    SELECT p.id FROM posts p
+     WHERE p.site_id = ? AND p.status = 'published'
+       AND p.content LIKE '%[[track:' || ? || ']]%'
+    UNION
+    SELECT p.id FROM playlist_tracks pt
+      JOIN posts p ON p.site_id = ? AND p.status = 'published'
+                  AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
+     WHERE pt.track_id = ?
+    UNION
+    SELECT p.id FROM posts p
+     WHERE p.site_id = ? AND p.status = 'published'
+       AND ? IS NOT NULL AND ? <> ''
+       AND p.content LIKE '%[[album:' || ? || ']]%'
+  `).all(siteId, t.id, siteId, t.id, siteId, t.album, t.album, t.album);
+  return rows.map((r) => r.id);
 }
 
Index: src/views/pages/admin-audio.ejs
===================================================================
--- src/views/pages/admin-audio.ejs	(revision 19e28cedce7ba0de89244415d0c1f285866cd75a)
+++ src/views/pages/admin-audio.ejs	(revision ca260d13a940f7563a540fad1f1945ef28e9160c)
@@ -91,4 +91,7 @@
         var _aaudCopy     = t('aaud.copy_click');
         var _aaudDelConfirm = t('aaud.delete_confirm');
+        var _aaudFediBtn     = t('aaud.fedi_open_btn');
+        var _aaudFediOn      = t('aaud.fedi_open_on');
+        var _aaudFediConfirm = t('aaud.fedi_open_confirm');
       %>
       <ul class="ax-list">
@@ -125,4 +128,19 @@
                         aria-label="<%= t.downloadable ? _aaudDlOn : _aaudDlOff %>" title="<%= t.downloadable ? _aaudDlOn : _aaudDlOff %>"
                         style="<%= t.downloadable ? 'color:var(--accent,#6b8f71)' : 'opacity:.5' %>">⬇</button>
+              <% } %>
+              <%# Openbaar op de fediverse, per track (Robin, 30-9). EENRICHTINGS, net
+                  als het vinkje in de post-editor: open is een slotje en geen knop meer,
+                  want een bestand dat al verspreid is haal je niet terug. Alleen bij een
+                  track met een eigen bestand; een link heeft niets om te openen. Een
+                  formulier en geen fetch: werkt zonder JS, en data-confirm vraagt het
+                  eerst (shell.ejs). %>
+              <% if (t.media_id) { %>
+                <% if (t.fedi_open) { %>
+                  <span class="ax-icon-btn ax-fedi-on" role="img" aria-label="<%= _aaudFediOn %>" title="<%= _aaudFediOn %>"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
+                <% } else { %>
+                  <form action="/admin/audio/<%= t.id %>/fedi-open" method="post" data-confirm="<%= _aaudFediConfirm %>" class="ax-track-fedi">
+                    <button type="submit" class="ax-icon-btn ax-fedi-off" aria-label="<%= _aaudFediBtn %>" title="<%= _aaudFediBtn %>"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></button>
+                  </form>
+                <% } %>
               <% } %>
               <form action="/admin/audio/<%= t.id %>/delete" method="post" data-confirm="<%= _aaudDelConfirm %>" class="ax-track-delete">
@@ -247,4 +265,10 @@
 }
 .ax-file-name[data-empty] { color: var(--ink-muted, var(--ink-soft)); }
+/* Openbaar op de fediverse: dicht is gedimd, net als een uitgezette download;
+   open is de accentkleur en geen knop meer, want terug kan niet. */
+.ax-track-fedi { display: inline; }
+.ax-fedi-off { opacity: .5; }
+.ax-fedi-off:hover, .ax-fedi-off:focus-visible { opacity: 1; }
+.ax-fedi-on { color: var(--accent, #6b8f71); cursor: default; }
 
 .ax-form-actions {
Index: test/fedi-open-per-track.test.js
===================================================================
--- test/fedi-open-per-track.test.js	(revision ca260d13a940f7563a540fad1f1945ef28e9160c)
+++ test/fedi-open-per-track.test.js	(revision ca260d13a940f7563a540fad1f1945ef28e9160c)
@@ -0,0 +1,142 @@
+// Fedi open per track, vanuit de mediamanager (Robin, 30-9).
+//
+// De vlag was altijd al per track (audio_tracks.fedi_open), maar aanzetten kon
+// alleen per POST, met het vinkje in de editor. Nu ook per track. Wat hier
+// bewaakt wordt:
+//
+//   1. openen zet de vlag en stuurt een Update voor ELKE gepubliceerde post van
+//      deze site waar de track in staat -- rechtstreeks, via album, via playlist.
+//      Anders krijgt een volger de speler pas bij de volgende bewerking.
+//   2. EENRICHTINGS: er is geen weg terug, ook niet via de algemene bijwerk-API.
+//   3. alleen een track met een eigen bestand, en alleen van deze site.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import os from 'os';
+import path from 'path';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+process.env.MEDIA_PATH = path.join(os.tmpdir(), 'klonkt-test-media');
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
+const audioMod = await import('../src/routes/admin-audio.js');
+const { openTrackOnFediverse } = audioMod;
+
+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, owner_id, is_public) VALUES (?,?,?,?,1)').run('s1', 'band', 'De Band', 'u1');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_public) VALUES (?,?,?,?,1)').run('s2', 'ander', 'Andere', 'u1');
+const site = (id) => db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
+
+const insMedia = db.prepare('INSERT INTO media (id, site_id, filename, storage_path, mime_type, size) VALUES (?,?,?,?,?,1000)');
+const insTrack = db.prepare('INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, fedi_open) VALUES (?,?,?,?,?,?,?,0)');
+insMedia.run('m1', 's1', 'zwaluwen.mp3', 'audio/zwaluwen.mp3', 'audio/mpeg');
+insMedia.run('m2', 's1', 'stil.mp3', 'audio/stil.mp3', 'audio/mpeg');
+insMedia.run('mb', 's2', 'buur.mp3', 'audio/buur.mp3', 'audio/mpeg');
+insTrack.run('ta1', 's1', 'Zwaluwen', 'De Band', 'Voorjaar', 200, 'm1');
+insTrack.run('ta3', 's1', 'Stil', 'De Band', null, 90, 'm2');       // hangt in geen enkele post
+insTrack.run('link', 's1', 'Alleen een link', 'De Band', null, 0, null);
+insTrack.run('tb1', 's2', 'Van de buurman', 'Andere', null, 210, 'mb');
+
+db.prepare('INSERT INTO playlists (id, site_id, title) VALUES (?,?,?)').run('pl1', 's1', 'Lente');
+db.prepare('INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,0)').run('pl1', 'ta1');
+
+const insPost = db.prepare('INSERT INTO posts (id, site_id, slug, title, content, status, author_id) VALUES (?,?,?,?,?,?,?)');
+insPost.run('p-direct', 's1', 'direct', 'Direct', 'Luister: [[track:ta1]]', 'published', 'u1');
+insPost.run('p-album', 's1', 'album', 'Album', '[[album:Voorjaar]]', 'published', 'u1');
+insPost.run('p-playlist', 's1', 'lijst', 'Lijst', '[[playlist:pl1]]', 'published', 'u1');
+insPost.run('p-concept', 's1', 'concept', 'Concept', '[[track:ta1]]', 'draft', 'u1');
+insPost.run('p-anders', 's1', 'anders', 'Anders', 'Geen muziek', 'published', 'u1');
+// Een post op een ANDERE site met het id van onze track: die is niet van ons.
+insPost.run('q-buur', 's2', 'buur', 'Buur', '[[track:ta1]]', 'published', 'u1');
+
+const open = (id) => db.prepare('SELECT fedi_open FROM audio_tracks WHERE id = ?').get(id).fedi_open;
+
+const { postsEmbeddingTrack } = await import('../src/services/music/index.js');
+
+test('de posts met een track: direct, via album, via playlist, en alleen van deze site', () => {
+  // Rechtstreeks getoetst, en niet alleen via het openen: daar vangt een tweede
+  // controle (post opnieuw ophalen met site_id) een fout hier nog op, maar deze
+  // functie is los te gebruiken, en een volgende aanroeper heeft die tweede
+  // wacht misschien niet.
+  assert.deepEqual(postsEmbeddingTrack('s1', 'ta1').sort(), ['p-album', 'p-direct', 'p-playlist']);
+  assert.deepEqual(postsEmbeddingTrack('s2', 'ta1'), [], 'een track van een andere site hoort bij niemand hier');
+});
+
+test('openen zet de vlag en stuurt elke post met de track opnieuw de deur uit', async () => {
+  const verstuurd = [];
+  const uit = openTrackOnFediverse(site('s1'), 'ta1', { deliver: (s, p) => { verstuurd.push(p.id); } });
+  await new Promise((r) => setTimeout(r, 20));
+  assert.equal(uit.ok, true);
+  assert.equal(uit.changed, true);
+  assert.equal(open('ta1'), 1);
+  assert.deepEqual(verstuurd.sort(), ['p-album', 'p-direct', 'p-playlist'],
+    'direct, via album en via playlist; geen concept, geen post zonder de track, geen post van een andere site');
+});
+
+test('nog een keer openen verandert niets en verstuurt niets', async () => {
+  const verstuurd = [];
+  const uit = openTrackOnFediverse(site('s1'), 'ta1', { deliver: (s, p) => { verstuurd.push(p.id); } });
+  await new Promise((r) => setTimeout(r, 20));
+  assert.equal(uit.changed, false);
+  assert.deepEqual(verstuurd, []);
+});
+
+test('een track die alleen een link is, heeft niets om te openen', () => {
+  const uit = openTrackOnFediverse(site('s1'), 'link', { deliver: () => {} });
+  assert.deepEqual([uit.ok, uit.reason], [false, 'no_file']);
+  assert.equal(open('link'), 0);
+});
+
+test('de track van een andere site blijft dicht', () => {
+  const uit = openTrackOnFediverse(site('s1'), 'tb1', { deliver: () => {} });
+  assert.deepEqual([uit.ok, uit.reason], [false, 'not_found']);
+  assert.equal(open('tb1'), 0, 'fedi_open is eenrichtings: een verkeerd geopend bestand haal je niet terug');
+});
+
+// ── Via de routes ────────────────────────────────────────────────────────────
+const express = (await import('express')).default;
+const app = express();
+app.use((req, res, next) => {
+  res.locals.site = site('s1');
+  res.locals.siteUrlBase = '';
+  req.session = { user: { id: 'u1', role: 'god', username: 'u1' } };
+  next();
+});
+app.use('/admin/audio', audioMod.default);
+const server = app.listen(0);
+server.unref();
+const base = `http://127.0.0.1:${server.address().port}`;
+test.after(() => server.close());
+
+test('de knop in de mediamanager opent de track', async () => {
+  const r = await fetch(`${base}/admin/audio/ta3/fedi-open`, { method: 'POST', redirect: 'manual' });
+  assert.equal(r.status, 302);
+  assert.match(r.headers.get('location') || '', /^\/admin\/audio\?success=/);
+  assert.equal(open('ta3'), 1);
+});
+
+test('er is geen weg terug, ook niet via de algemene bijwerk-API', async () => {
+  const r = await fetch(`${base}/admin/audio/api/ta1`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fedi_open: false }),
+  });
+  assert.notEqual(r.status, 200, 'de API neemt fedi_open niet aan');
+  assert.equal(open('ta1'), 1, 'en de track blijft open');
+});
+
+test('de lijst biedt openen alleen aan waar het kan', async () => {
+  const html = await (await fetch(`${base}/admin/audio`)).text();
+  assert.ok(!html.includes('/admin/audio/link/fedi-open'), 'een link-track krijgt geen knop');
+  assert.ok(!html.includes('/admin/audio/ta1/fedi-open'), 'een open track krijgt geen knop meer, alleen het slotje');
+  assert.ok(html.includes('ax-fedi-on'), 'het slotje staat er wel');
+});
+
+test('een dichte track met een bestand krijgt de knop, met bevestiging', async () => {
+  insMedia.run('m4', 's1', 'nieuw.mp3', 'audio/nieuw.mp3', 'audio/mpeg');
+  insTrack.run('ta4', 's1', 'Nieuw', 'De Band', null, 120, 'm4');
+  const html = await (await fetch(`${base}/admin/audio`)).text();
+  const i = html.indexOf('action="/admin/audio/ta4/fedi-open"');
+  assert.ok(i >= 0, 'de knop hoort er te staan');
+  assert.ok(html.slice(i, i + 400).includes('data-confirm='), 'en eerst te vragen, want terug kan niet');
+});
