Index: CHANGELOG.de.md
===================================================================
--- CHANGELOG.de.md	(revision badbba8004db93303d58478966fab8bf4927ff99)
+++ CHANGELOG.de.md	(revision 8cf8b5f1195ab37aa4bc2f5248897f0cb8b5d0b8)
@@ -7,4 +7,7 @@
 
 ### Hinzugefügt
+- **Cover-Art bei offen geteiltem Audio.** Ein offen im Fediverse geteilter Track trägt jetzt sein
+  Cover (oder das Beitragscover) mit, sodass Audio-Player, die Artwork unterstützen, es statt einer
+  leeren Kachel anzeigen.
 - **Einen Beitrag im Fediverse melden.** Von einem Fediverse-Beitrag aus kannst du ihn jetzt den
   Moderatoren seines Heimatservers melden, mit optionaler Begründung — und meldet jemand deine Seite,
Index: CHANGELOG.md
===================================================================
--- CHANGELOG.md	(revision badbba8004db93303d58478966fab8bf4927ff99)
+++ CHANGELOG.md	(revision 8cf8b5f1195ab37aa4bc2f5248897f0cb8b5d0b8)
@@ -7,4 +7,6 @@
 
 ### Added
+- **Cover art on openly shared audio.** A track shared openly on the fediverse now carries its
+  cover art (or the post cover), so audio players that support artwork show it instead of a blank tile.
 - **Report a post to the fediverse.** From a fediverse post you can now report it to the moderators
   of its own home server, with an optional reason — and if someone reports your site, the report
Index: CHANGELOG.nl.md
===================================================================
--- CHANGELOG.nl.md	(revision badbba8004db93303d58478966fab8bf4927ff99)
+++ CHANGELOG.nl.md	(revision 8cf8b5f1195ab37aa4bc2f5248897f0cb8b5d0b8)
@@ -7,4 +7,7 @@
 
 ### Toegevoegd
+- **Cover-art op openbaar gedeelde audio.** Een track die je openbaar op de fediverse deelt draagt
+  nu z'n cover-art mee (of de post-cover), zodat audiospelers die artwork ondersteunen die tonen in
+  plaats van een leeg vlak.
 - **Rapporteer een post op de fediverse.** Vanaf een fediverse-post kun je die nu melden bij de
   moderators van de eigen server van die post, met een optionele reden — en meldt iemand jouw site,
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision badbba8004db93303d58478966fab8bf4927ff99)
+++ src/services/ActivityPubService.js	(revision 8cf8b5f1195ab37aa4bc2f5248897f0cb8b5d0b8)
@@ -299,11 +299,16 @@
       const fn = r.filename || (r.storage_path || '').split('/').pop();
       if (!fn || seenA.has(fn)) return; seenA.add(fn);
-      openAudio.push({ type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' });
+      const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
+      // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
+      // Mastodon renders it as the artwork thumbnail on its native audio player.
+      const art = abs(r.cover_url || post.cover_image_url || null);
+      if (art) a.icon = { type: 'Image', mediaType: mediaType(art), url: art };
+      openAudio.push(a);
     };
-    const SEL = 'SELECT t.title, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
+    const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
     try {
       for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
       for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
-      for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
+      for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
     } catch { /* non-fatal */ }
   }
Index: test/audio-attachment-art.test.js
===================================================================
--- test/audio-attachment-art.test.js	(revision 8cf8b5f1195ab37aa4bc2f5248897f0cb8b5d0b8)
+++ test/audio-attachment-art.test.js	(revision 8cf8b5f1195ab37aa4bc2f5248897f0cb8b5d0b8)
@@ -0,0 +1,49 @@
+// Cover art on federated audio — a fedi_open track's Audio attachment carries the track
+// cover (else the post cover) as an AS2 `icon`, so Mastodon's native audio player can show
+// artwork instead of a blank tile. In-memory SQLite. Run: npm test
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+const BASE = 'https://test.example';
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'demo', 'Demo', 'u1', 1);
+const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
+site.primary_slug = 'demo';
+
+db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+  .run('m1', 's1', 'song.mp3', 'audio/mpeg', 1000, '/x/song.mp3');
+db.prepare('INSERT INTO audio_tracks (id, site_id, title, media_id, cover_url, fedi_open) VALUES (?,?,?,?,?,?)')
+  .run('t1', 's1', 'Song One', 'm1', '/media/art.webp', 1);
+db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+  .run('m2', 's1', 'bare.mp3', 'audio/mpeg', 1000, '/x/bare.mp3');
+db.prepare('INSERT INTO audio_tracks (id, site_id, title, media_id, fedi_open) VALUES (?,?,?,?,?)')
+  .run('t2', 's1', 'Bare Track', 'm2', 1);
+
+test('a fedi_open track carries its cover as the Audio attachment icon', () => {
+  const post = { id: 'p1', slug: 'x', title: '', content: '<p>[[track:t1]]</p>', tags: '[]', created_at: '2026-01-01T00:00:00Z' };
+  const att = (AP.buildNote(BASE, site, post).attachment || []).find((a) => a.type === 'Audio');
+  assert.ok(att, 'Audio attachment present');
+  assert.ok(att.icon, 'icon present');
+  assert.equal(att.icon.type, 'Image');
+  assert.equal(att.icon.url, `${BASE}/media/art.webp`);
+});
+
+test('a coverless track falls back to the post cover', () => {
+  const post = { id: 'p2', slug: 'y', title: '', content: '<p>[[track:t2]]</p>', tags: '[]', cover_image_url: '/media/post.webp', created_at: '2026-01-01T00:00:00Z' };
+  const att = (AP.buildNote(BASE, site, post).attachment || []).find((a) => a.type === 'Audio');
+  assert.equal(att.icon.url, `${BASE}/media/post.webp`);
+});
+
+test('no track cover and no post cover → no icon', () => {
+  const post = { id: 'p3', slug: 'z', title: '', content: '<p>[[track:t2]]</p>', tags: '[]', created_at: '2026-01-01T00:00:00Z' };
+  const att = (AP.buildNote(BASE, site, post).attachment || []).find((a) => a.type === 'Audio');
+  assert.ok(!('icon' in att));
+});
