Index: CHANGELOG.de.md
===================================================================
--- CHANGELOG.de.md	(revision 92a2c4643b82e26c44f79b8ad0f7aa15ec0ece34)
+++ CHANGELOG.de.md	(revision 7d19465e40d4878df20b30446977239bc27f911c)
@@ -21,4 +21,10 @@
 
 ### Behoben
+- **Ein geboosteter Videobeitrag behält sein Video im Zirkel.** Das Boosten
+  eines Nur-Video-Beitrags (Loops.video) speicherte ihn ohne Medien, sodass der
+  Zirkel eine nackte Textkachel statt eines Video-Thumbnails zeigte; erneutes
+  Boosten konnte das Video sogar aus einer bereits gecachten Kopie löschen.
+  Boosts tragen jetzt die vollen typisierten Medien, und ein Refresh löscht
+  gecachte Medien nicht mehr.
 - **Der Titel der Interaktionsseite folgt deiner Sprache.** "Interacteer via de
   fediverse" stand fest auf Niederländisch im Browser-Tab, auch auf einer
Index: CHANGELOG.md
===================================================================
--- CHANGELOG.md	(revision 92a2c4643b82e26c44f79b8ad0f7aa15ec0ece34)
+++ CHANGELOG.md	(revision 7d19465e40d4878df20b30446977239bc27f911c)
@@ -20,4 +20,9 @@
 
 ### Fixed
+- **A boosted video post keeps its video in the Circle.** Boosting a video-only
+  post (Loops.video) stored it without its media, so the Circle showed a bare
+  text tile instead of a video thumbnail; re-boosting could even wipe the video
+  from an already-cached copy. Boosts now carry the full typed media, and a
+  refresh never erases cached media.
 - **The interact page title follows your language.** "Interacteer via de
   fediverse" was hardcoded Dutch in the browser tab, even on an English site.
Index: CHANGELOG.nl.md
===================================================================
--- CHANGELOG.nl.md	(revision 92a2c4643b82e26c44f79b8ad0f7aa15ec0ece34)
+++ CHANGELOG.nl.md	(revision 7d19465e40d4878df20b30446977239bc27f911c)
@@ -20,4 +20,9 @@
 
 ### Opgelost
+- **Een geboostte videopost houdt zijn video in de Cirkel.** Een video-only post
+  (Loops.video) boosten sloeg hem op zonder media, waardoor de Cirkel een kale
+  teksttegel toonde in plaats van een videothumbnail; opnieuw boosten kon de
+  video zelfs wissen uit een al-gecachte kopie. Boosts dragen nu de volledige
+  getypeerde media mee, en een refresh wist gecachte media nooit meer.
 - **De titel van de interactiepagina volgt je taal.** "Interacteer via de
   fediverse" stond hardcoded in het Nederlands in het browsertabblad, ook op een
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 92a2c4643b82e26c44f79b8ad0f7aa15ec0ece34)
+++ src/services/ActivityPubService.js	(revision 7d19465e40d4878df20b30446977239bc27f911c)
@@ -1828,4 +1828,8 @@
     cw: note.summary || '',
     images,
+    // Full typed media (incl. video/mp4) for the timeline cache. `images` above is
+    // image-only for the interact page preview; a boosted video-only post (Loops)
+    // lost its media entirely because upsertBoostedNote only saw `images`.
+    media: mediaFromNote(note),
     threadInboxes,                                          // every ancestor author's inbox
     localPostId: localTgt ? localTgt.post_id : '',          // our post this belongs to (if any)
@@ -2014,5 +2018,9 @@
   if (!slug || !note || !note.object_uri) return;
   const id = note.object_uri;
-  const media = JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
+  // Prefer the full typed media (incl. video/mp4 — a Loops boost is video-only and
+  // rendered a bare text tile); fall back to the image-only list for older callers.
+  const media = (note.media && note.media !== '[]')
+    ? note.media
+    : JSON.stringify((note.images || []).map((u) => ({ url: u, type: 'image/jpeg' })));
   try {
     const r = tlStmts().ins.run(id, slug, note.actor_uri || '', note.actor_name || '', note.actor_handle || '',
@@ -2023,6 +2031,10 @@
       // resolved note. Without this a row cached without its cover (or with
       // stale content) stayed stale forever — even boosting again didn't heal it.
-      db.prepare('UPDATE ap_timeline SET content = ?, media_json = ?, nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE slug = ? AND id = ?')
-        .run(note.content || '', media, note.sensitive ? 1 : 0, note.cw || null, note.url || null, slug, id);
+      // Keep the CACHED media when the resolve yielded none: an empty re-resolve
+      // used to clobber a good media_json (the followed copy had the video, the
+      // boost wiped it to []).
+      db.prepare(`UPDATE ap_timeline SET content = ?, media_json = CASE WHEN ? = '[]' THEN media_json ELSE ? END,
+                  nsfw = ?, cw = ?, url = COALESCE(?, url) WHERE slug = ? AND id = ?`)
+        .run(note.content || '', media, media, note.sensitive ? 1 : 0, note.cw || null, note.url || null, slug, id);
     }
   } catch { /* ignore */ }
Index: test/boost-media.test.js
===================================================================
--- test/boost-media.test.js	(revision 7d19465e40d4878df20b30446977239bc27f911c)
+++ test/boost-media.test.js	(revision 7d19465e40d4878df20b30446977239bc27f911c)
@@ -0,0 +1,65 @@
+// Cirkel/boost-media: een geboostte video-only post (Loops) verloor zijn media —
+// upsertBoostedNote keek alleen naar note.images (afbeeldingen) en de refresh
+// overschreef bestaande media_json met []. Dekt de typed-media opslag, de
+// niet-clobberen-refresh en de images-fallback af.
+//
+// Run: npm test   (= node --test)
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+const AP = await import('../src/services/ActivityPubService.js');
+
+dbMod.initializeDatabase();
+
+const VIDEO_MEDIA = JSON.stringify([{ url: 'https://cdn.test/clip.720p.mp4', type: 'video/mp4' }]);
+
+function mediaOf(id) {
+  const r = db.prepare('SELECT media_json FROM ap_timeline WHERE slug = ? AND id = ?').get('me', id);
+  return r ? r.media_json : null;
+}
+
+test('boost van een video-only post bewaart de video in media_json', () => {
+  AP.upsertBoostedNote('me', {
+    object_uri: 'https://loops.test/n/1', actor_uri: 'https://loops.test/u/a',
+    actor_name: 'A', content: '<p>x</p>', media: VIDEO_MEDIA, images: [],
+  });
+  const m = JSON.parse(mediaOf('https://loops.test/n/1'));
+  assert.equal(m.length, 1);
+  assert.equal(m[0].type, 'video/mp4');
+});
+
+test('refresh met lege media clobbert een bestaande media_json NIET', () => {
+  // rij bestaat al (met video); een re-upsert die niks resolvede mag hem niet wissen
+  AP.upsertBoostedNote('me', {
+    object_uri: 'https://loops.test/n/1', actor_uri: 'https://loops.test/u/a',
+    actor_name: 'A', content: '<p>x2</p>', media: '[]', images: [],
+  });
+  const m = JSON.parse(mediaOf('https://loops.test/n/1'));
+  assert.equal(m.length, 1, 'media_json is gewist door een lege refresh');
+  assert.equal(m[0].type, 'video/mp4');
+});
+
+test('refresh met nieuwe media werkt de rij wel bij', () => {
+  const newer = JSON.stringify([{ url: 'https://cdn.test/clip2.mp4', type: 'video/mp4' }]);
+  AP.upsertBoostedNote('me', {
+    object_uri: 'https://loops.test/n/1', actor_uri: 'https://loops.test/u/a',
+    actor_name: 'A', content: '<p>x3</p>', media: newer, images: [],
+  });
+  assert.equal(JSON.parse(mediaOf('https://loops.test/n/1'))[0].url, 'https://cdn.test/clip2.mp4');
+});
+
+test('images-fallback (oude callers zonder note.media) blijft werken', () => {
+  AP.upsertBoostedNote('me', {
+    object_uri: 'https://old.test/n/2', actor_uri: 'https://old.test/u/b',
+    actor_name: 'B', content: '<p>y</p>', images: ['https://old.test/img.jpg'],
+  });
+  const m = JSON.parse(mediaOf('https://old.test/n/2'));
+  assert.equal(m[0].url, 'https://old.test/img.jpg');
+  assert.equal(m[0].type, 'image/jpeg');
+});
