[object Object]

← back to Marketing Command Center

chore: lint+refactor, fix channels postLinkedIn video routing, vN (session close)

60ffd0e80acf1ec3fb3c043bbcb6f11ab7aac81b · 2026-07-30 15:28:45 -0700 · Steve

- channels postLinkedIn: route videoUrl → /rest/videos (init+multipart PUT+finalize)
  instead of the images endpoint; add Content-Type octet-stream to image PUT.
  (lint-flagged critical: 'video' in mediaTypes exposed the image-only path.)
- linkedin/index.js: drop dead ETag case-fallback (Fetch headers are case-insensitive).
- version bump.

Files touched

Diff

commit 60ffd0e80acf1ec3fb3c043bbcb6f11ab7aac81b
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 30 15:28:45 2026 -0700

    chore: lint+refactor, fix channels postLinkedIn video routing, vN (session close)
    
    - channels postLinkedIn: route videoUrl → /rest/videos (init+multipart PUT+finalize)
      instead of the images endpoint; add Content-Type octet-stream to image PUT.
      (lint-flagged critical: 'video' in mediaTypes exposed the image-only path.)
    - linkedin/index.js: drop dead ETag case-fallback (Fetch headers are case-insensitive).
    - version bump.
---
 modules/channels/index.js | 51 ++++++++++++++++++++++++++++++++++++-----------
 modules/linkedin/index.js |  2 +-
 package.json              |  2 +-
 3 files changed, 41 insertions(+), 14 deletions(-)

diff --git a/modules/channels/index.js b/modules/channels/index.js
index b7ad9f2..3a18d4d 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -657,20 +657,47 @@ async function postLinkedIn(content) {
   if (!token || !author) return [{ ok: false, error: 'LinkedIn not configured (token + org/author URN)' }];
   const H = { Authorization: `Bearer ${token}`, 'LinkedIn-Version': '202401', 'X-Restli-Protocol-Version': '2.0.0' };
   let mediaId = null;
+  // videoUrl → native VIDEO upload (videos endpoint + multipart PUT + finalize);
+  // mediaUrl → single-PUT IMAGE upload. Routing image vs video must match the
+  // media kind or LinkedIn rejects it (initializeUpload endpoints are distinct).
+  const isVideo = !!content.videoUrl && !content.mediaUrl;
   const src = content.mediaUrl || content.videoUrl;
   if (src) {
-    const init = await fetch('https://api.linkedin.com/rest/images?action=initializeUpload', {
-      method: 'POST', headers: { ...H, 'Content-Type': 'application/json' },
-      body: JSON.stringify({ initializeUploadRequest: { owner: author } }),
-    });
-    const ij = await init.json().catch(() => ({}));
-    const up = ij.value && ij.value.uploadUrl; mediaId = ij.value && ij.value.image;
-    if (!init.ok || !up) return [{ ok: false, error: `image init ${init.status}: ${JSON.stringify(ij).slice(0, 150)}` }];
-    const img = await fetch(src);
-    if (!img.ok) return [{ ok: false, error: `couldn't fetch media (${img.status})` }];
-    const buf = Buffer.from(await img.arrayBuffer());
-    const put = await fetch(up, { method: 'PUT', headers: { Authorization: `Bearer ${token}` }, body: buf });
-    if (!put.ok) return [{ ok: false, error: `image upload ${put.status}` }];
+    const media = await fetch(src);
+    if (!media.ok) return [{ ok: false, error: `couldn't fetch media (${media.status})` }];
+    const buf = Buffer.from(await media.arrayBuffer());
+    if (isVideo) {
+      const init = await fetch('https://api.linkedin.com/rest/videos?action=initializeUpload', {
+        method: 'POST', headers: { ...H, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ initializeUploadRequest: { owner: author, fileSizeBytes: buf.length, uploadCaptions: false, uploadThumbnail: false } }),
+      });
+      const ij = await init.json().catch(() => ({}));
+      const v = ij.value;
+      if (!init.ok || !v || !v.video) return [{ ok: false, error: `video init ${init.status}: ${JSON.stringify(ij).slice(0, 150)}` }];
+      mediaId = v.video;
+      const etags = [];
+      for (const ins of (v.uploadInstructions || [])) {
+        const first = Number(ins.firstByte), last = Number(ins.lastByte);
+        const put = await fetch(ins.uploadUrl, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: buf.subarray(first, last + 1) });
+        if (!put.ok) return [{ ok: false, error: `video upload part ${put.status}` }];
+        etags.push(put.headers.get('etag'));
+      }
+      const fin = await fetch('https://api.linkedin.com/rest/videos?action=finalizeUpload', {
+        method: 'POST', headers: { ...H, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ finalizeUploadRequest: { video: mediaId, uploadToken: v.uploadToken || '', uploadedPartIds: etags } }),
+      });
+      if (!fin.ok) return [{ ok: false, error: `video finalize ${fin.status}` }];
+    } else {
+      const init = await fetch('https://api.linkedin.com/rest/images?action=initializeUpload', {
+        method: 'POST', headers: { ...H, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ initializeUploadRequest: { owner: author } }),
+      });
+      const ij = await init.json().catch(() => ({}));
+      const up = ij.value && ij.value.uploadUrl; mediaId = ij.value && ij.value.image;
+      if (!init.ok || !up) return [{ ok: false, error: `image init ${init.status}: ${JSON.stringify(ij).slice(0, 150)}` }];
+      const put = await fetch(up, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: buf });
+      if (!put.ok) return [{ ok: false, error: `image upload ${put.status}` }];
+    }
   }
   const body = {
     author, commentary: content.caption || '', visibility: 'PUBLIC',
diff --git a/modules/linkedin/index.js b/modules/linkedin/index.js
index 22988c8..8dd709d 100644
--- a/modules/linkedin/index.js
+++ b/modules/linkedin/index.js
@@ -70,7 +70,7 @@ async function liUploadMedia({ token, author, kind, ref }) {
     const first = Number(ins.firstByte), last = Number(ins.lastByte);
     const pr = await fetch(ins.uploadUrl, { method: 'PUT', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' }, body: bytes.subarray(first, last + 1) });
     if (!pr.ok) throw new Error(`video PUT part ${pr.status}`);
-    etags.push(pr.headers.get('etag') || pr.headers.get('ETag'));
+    etags.push(pr.headers.get('etag'));
   }
   const fr = await fetch('https://api.linkedin.com/rest/videos?action=finalizeUpload', {
     method: 'POST', headers: { ...liHeaders(token), 'Content-Type': 'application/json' },
diff --git a/package.json b/package.json
index 306067c..5ff3b70 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "marketing-command-center",
-  "version": "1.7.1",
+  "version": "1.8.0",
   "description": "DW Marketing Command Center — Constant Contact, marketing calendar, suggested copy, on-demand layouts",
   "main": "server.js",
   "scripts": {

← ef6776b linkedin(mcc): incorporate native image/video posting into t  ·  back to Marketing Command Center  ·  fix(linkedin): blank media title default + video-wins when b 37441d3 →