← 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
M modules/channels/index.js
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 →