Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 67c1f2400d6b1962aadaceb8f55ab319a781a58b)
+++ 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 */ }
