[object Object]

← back to Marketing Command Center

youtube: build resumable videos.insert upload pipeline (postYouTube) + token refresh

85d08d7cf596932e2674e86f08c316d54df41a82 · 2026-07-21 13:43:43 -0700 · Steve

Files touched

Diff

commit 85d08d7cf596932e2674e86f08c316d54df41a82
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 21 13:43:43 2026 -0700

    youtube: build resumable videos.insert upload pipeline (postYouTube) + token refresh
---
 modules/channels/index.js | 65 ++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 59 insertions(+), 6 deletions(-)

diff --git a/modules/channels/index.js b/modules/channels/index.js
index 6fbd573..c9deb80 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -273,8 +273,9 @@ function platformStatus() {
       accounts: env('YOUTUBE_CHANNEL_TITLE') ? [env('YOUTUBE_CHANNEL_TITLE')] : (env('YOUTUBE_CHANNEL_ID') ? [env('YOUTUBE_CHANNEL_ID')] : []),
       needs: 'Google Cloud project + YouTube Data API v3 enabled + OAuth consent (youtube.upload scope) → channel refresh token.',
       scopes: ['https://www.googleapis.com/auth/youtube.upload'],
-      // Connecting works, but uploads aren't built — a "live" post will stage.
-      caveat: 'Connecting works, but the video upload pipeline isn’t wired yet — posts stage instead of going live.',
+      // Resumable videos.insert upload pipeline is wired (postYouTube). Uploads land on
+      // the connected token's channel; defaults to privacyStatus 'private'.
+      caveat: 'Uploads go to the connected channel; posts default to Private (set content.privacy to publish public).',
     },
     bluesky: {
       label: 'Bluesky', icon: '🦋',
@@ -460,10 +461,62 @@ async function postTikTok(content) {
   const j = await r.json().catch(() => ({}));
   return [{ ok: r.ok && !j.error?.code, id: j.data?.publish_id, error: r.ok ? null : JSON.stringify(j.error || {}) }];
 }
-async function postYouTube() {
-  // videos.insert is a resumable multipart upload — needs the binary; out of scope
-  // for URL-only v1. Staged with a clear note until the upload pipeline is wired.
-  return [{ ok: false, error: 'YouTube upload pipeline not wired (needs resumable binary upload)' }];
+// YouTube access tokens live ~1h, so mint a fresh one from the stored refresh token
+// on every post (grant_type=refresh_token). Falls back to any stored access token.
+async function youtubeAccessToken() {
+  const yt = readTokens().youtube || {};
+  const rt = yt.refresh_token || env('YOUTUBE_REFRESH_TOKEN');
+  const cid = env('YOUTUBE_CLIENT_ID') || env('GOOGLE_CLIENT_ID');
+  const cs = env('YOUTUBE_CLIENT_SECRET') || env('GOOGLE_CLIENT_SECRET');
+  if (rt && cid && cs) {
+    const body = new URLSearchParams({ client_id: cid, client_secret: cs, refresh_token: rt, grant_type: 'refresh_token' });
+    const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body });
+    const j = await r.json().catch(() => ({}));
+    if (j.access_token) return j.access_token;
+  }
+  return yt.access_token || env('YOUTUBE_ACCESS_TOKEN') || null;
+}
+
+// YouTube Data API videos.insert — a RESUMABLE binary upload. Unlike the URL-based
+// channels, YouTube needs the actual bytes: we fetch the source video, open a
+// resumable session (metadata POST → upload URL), then PUT the bytes. Posts land on
+// whichever channel the connected token authorized (pick the brand channel at OAuth
+// time). Defaults to privacyStatus 'private' unless content.privacy overrides.
+async function postYouTube(content) {
+  const src = content.videoUrl || content.mediaUrl;
+  if (!src) return [{ ok: false, error: 'YouTube needs a video (videoUrl/mediaUrl) — it never posts an image alone' }];
+  const token = await youtubeAccessToken();
+  if (!token) return [{ ok: false, error: 'YouTube not connected (no refresh/access token)' }];
+  const vr = await fetch(src);
+  if (!vr.ok) return [{ ok: false, error: `couldn't fetch source video (${vr.status}) from ${src}` }];
+  const ctype = vr.headers.get('content-type') || 'video/*';
+  const buf = Buffer.from(await vr.arrayBuffer());
+  const cap = (content.caption || 'Designer Wallcoverings').trim();
+  const title = (content.title || cap.split('\n')[0] || 'Designer Wallcoverings').slice(0, 100);
+  const description = content.description || cap;
+  const privacyStatus = content.privacy || 'private';
+  // 1) open a resumable upload session
+  const init = await fetch('https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status', {
+    method: 'POST',
+    headers: {
+      authorization: `Bearer ${token}`,
+      'content-type': 'application/json; charset=UTF-8',
+      'x-upload-content-type': ctype,
+      'x-upload-content-length': String(buf.length),
+    },
+    body: JSON.stringify({
+      snippet: { title, description, tags: content.tags || ['wallpaper', 'designerwallcoverings', 'interiordesign'], categoryId: env('YOUTUBE_CATEGORY_ID') || '22' },
+      status: { privacyStatus, selfDeclaredMadeForKids: false },
+    }),
+  });
+  if (!init.ok) { const e = await init.text().catch(() => ''); return [{ ok: false, error: `youtube init ${init.status}: ${e.slice(0, 200)}` }]; }
+  const uploadUrl = init.headers.get('location');
+  if (!uploadUrl) return [{ ok: false, error: 'youtube resumable init returned no upload URL' }];
+  // 2) upload the bytes in a single PUT
+  const put = await fetch(uploadUrl, { method: 'PUT', headers: { 'content-type': ctype, 'content-length': String(buf.length) }, body: buf });
+  const pj = await put.json().catch(() => ({}));
+  if (!put.ok || pj.error) return [{ ok: false, error: `youtube upload ${put.status}: ${JSON.stringify(pj.error || {}).slice(0, 200)}` }];
+  return [{ ok: true, id: pj.id, url: pj.id ? `https://youtu.be/${pj.id}` : null }];
 }
 // Rich-text FACETS for a Bluesky post — makes #hashtags and links clickable.
 // Per the AT Protocol spec (docs.bsky.app), facet index offsets are UTF-8 BYTE

← ed9e2e2 channels: derive TikTok + YouTube account handles from env (  ·  back to Marketing Command Center  ·  youtube: add /youtube/visibility endpoint (flip private↔publ e10410d →