← back to Dw Marketing Reels
reels UI: Post to YouTube button on each studio video chip
276feb45cbdcb9ab47c9e578ec1d41084b55b6b4 · 2026-08-25 10:31:27 -0700 · steve
Adds POST /api/youtube (uploads one studio mp4 to the DW channel as UNLISTED
via the youtube-upload skill) + a per-chip button in the #sec-videos gallery.
Unlisted is the safe/reversible default; the returned watch link is Steve's to
flip Public in Studio. Gated by ALLOW_BUILD so the public Kamatera deploy
answers 'runs on the studio host' (no python/secrets there). Path is
realpath+root guarded like /studio-media/.
Files touched
M public/index.htmlM server.js
Diff
commit 276feb45cbdcb9ab47c9e578ec1d41084b55b6b4
Author: steve <steve@designerwallcoverings.com>
Date: Tue Aug 25 10:31:27 2026 -0700
reels UI: Post to YouTube button on each studio video chip
Adds POST /api/youtube (uploads one studio mp4 to the DW channel as UNLISTED
via the youtube-upload skill) + a per-chip button in the #sec-videos gallery.
Unlisted is the safe/reversible default; the returned watch link is Steve's to
flip Public in Studio. Gated by ALLOW_BUILD so the public Kamatera deploy
answers 'runs on the studio host' (no python/secrets there). Path is
realpath+root guarded like /studio-media/.
---
public/index.html | 25 +++++++++++++++++++++++++
server.js | 48 +++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 72 insertions(+), 1 deletion(-)
diff --git a/public/index.html b/public/index.html
index d33e139..b5e1027 100644
--- a/public/index.html
+++ b/public/index.html
@@ -398,10 +398,35 @@ async function loadVideos() {
<div class="when">🕓 ${fmt(new Date(v.mtime).toISOString())}</div>
<div class="titles">${escapeHtml(v.path)}</div>
<div class="row"><a href="${v.url}" download>Download</a>
+ <button class="ghost tiny yt-one" data-path="${escapeAttr(v.path)}">▶ Post to YouTube</button>
<span style="font-size:12px;color:#9a917f">${v.kb} KB</span></div>
+ <div class="yt-out" style="font-size:12px;color:#8a8272;margin-top:6px"></div>
</div>
</div>`).join('') : '<p style="color:#8a8272">No studio videos yet — queue one from the Video Studio buttons.</p>';
}
+// per-video "Post to YouTube" (delegated). Uploads UNLISTED (safe default); the returned
+// watch link is Steve's to flip Public in YouTube Studio. Studio-host only — the public
+// deploy answers "runs on the studio host", surfaced verbatim in the card.
+$('#videos').addEventListener('click', async e => {
+ const b = e.target.closest('.yt-one'); if (!b) return;
+ const out = b.closest('.reel').querySelector('.yt-out');
+ const label = b.textContent; b.disabled = true; b.textContent = '⏳ Uploading…';
+ if (out) out.textContent = 'Uploading to YouTube (unlisted)…';
+ try {
+ const j = await (await fetch('api/youtube', { method:'POST', headers:{'content-type':'application/json'},
+ body: JSON.stringify({ path: b.dataset.path }) })).json();
+ if (j.ok) {
+ b.textContent = '✓ On YouTube';
+ if (out) out.innerHTML = `✓ Uploaded <b>unlisted</b> · <a href="${j.watch}" target="_blank" rel="noopener">${escapeHtml(j.watch)}</a> — flip to Public in YouTube Studio.`;
+ } else {
+ b.disabled = false; b.textContent = label;
+ if (out) out.textContent = '✗ ' + (j.error || 'upload failed');
+ }
+ } catch (err) {
+ b.disabled = false; b.textContent = label;
+ if (out) out.textContent = '✗ ' + err;
+ }
+});
async function loadJobs() {
let jobs = []; try { jobs = await (await fetch('api/video/jobs')).json(); } catch {}
const active = jobs.filter(j => j.status === 'queued' || j.status === 'running');
diff --git a/server.js b/server.js
index 97dc2f6..d1abec0 100644
--- a/server.js
+++ b/server.js
@@ -8,7 +8,8 @@ import { readFile, writeFile, stat, readdir } from 'node:fs/promises';
import { createReadStream, existsSync, realpathSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
-import { dirname, join, extname, normalize, sep } from 'node:path';
+import { dirname, join, basename, extname, normalize, sep } from 'node:path';
+import { homedir } from 'node:os';
const ROOT = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 9848);
@@ -17,6 +18,10 @@ const PASS = process.env.MKT_PASS || 'DW2024!';
// Generation needs the local render toolchain (Chrome/ffmpeg). On the public
// Kamatera deploy set ALLOW_BUILD=0 — it serves reels only; Mac3 renders + pushes.
const ALLOW_BUILD = process.env.ALLOW_BUILD !== '0';
+// The youtube-upload skill's uploader (python, reads YT_UPLOAD_* from secrets-manager/.env).
+// Only present on the studio/render host (where ALLOW_BUILD=1); the public Kamatera deploy
+// has neither python creds nor the skill, so the /api/youtube door is gated by ALLOW_BUILD.
+const YT_UPLOAD = join(homedir(), '.claude', 'skills', 'youtube-upload', 'scripts', 'upload.py');
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
'.json': 'application/json', '.mp4': 'video/mp4', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml' };
@@ -283,6 +288,47 @@ const server = http.createServer(async (req, res) => {
out.sort((a, b) => b.mtime - a.mtime);
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(out));
}
+ // POST /api/youtube → upload ONE studio video to the DW YouTube channel as UNLISTED.
+ // Uploading unlisted is the safe, reversible default (not a public broadcast, deletable);
+ // making it PUBLIC stays a gated action Steve flips in YouTube Studio via the returned link.
+ // Runs only on the studio host (ALLOW_BUILD=1) where python3 + the YT_UPLOAD_* secrets live —
+ // the public Kamatera deploy (ALLOW_BUILD=0) returns the same "studio host" message as build.
+ if (p === '/api/youtube' && req.method === 'POST') {
+ if (!ALLOW_BUILD) return res.writeHead(400, { 'content-type': 'application/json' })
+ .end(JSON.stringify({ error: 'YouTube upload runs on the studio host (Mac3).' }));
+ if (building) return res.writeHead(409, { 'content-type': 'application/json' })
+ .end(JSON.stringify({ error: 'a job is already running' }));
+ const body = await readBody(req).catch(() => ({}));
+ // Resolve the studio-media rel path to a real .mp4 under videos/ | reels/ | output/,
+ // dereferencing symlinks so a crafted path can't escape (same guard as /studio-media/).
+ const rel = String(body.path || '').replace(/^studio-media\//, '');
+ let fp = normalize(join(ROOT, rel));
+ try { fp = realpathSync(fp); } catch { return res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'file not found' })); }
+ const okRoot = fp.startsWith(join(ROOT, 'videos') + sep) || fp.startsWith(join(ROOT, 'reels') + sep) || fp.startsWith(join(ROOT, 'output') + sep);
+ if (!(okRoot && extname(fp) === '.mp4' && existsSync(fp)))
+ return res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'not a studio video' }));
+ const title = ((String(body.title || '').trim() || basename(fp, '.mp4').replace(/[-_]+/g, ' ')).slice(0, 84)
+ + ' | Designer Wallcoverings').slice(0, 100);
+ building = true; lastLog = '';
+ try {
+ const env = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ''}` };
+ const result = await new Promise(resolve => {
+ const ch = spawn('python3', [YT_UPLOAD, '--file', fp, '--title', title, '--privacy', 'unlisted',
+ '--tags', 'wallpaper,wallcoverings,interiordesign,DesignerWallcoverings'], { cwd: ROOT, env });
+ let out = '';
+ ch.stdout.on('data', d => { out += d; lastLog += d; });
+ ch.stderr.on('data', d => { out += d; lastLog += d; });
+ ch.on('error', e => resolve({ code: -1, out: 'spawn error: ' + e }));
+ ch.on('close', code => resolve({ code, out }));
+ });
+ building = false; lastLog += `\n[youtube exit ${result.code}]`;
+ const m = /UPLOAD_OK\s+(\S+)/.exec(result.out);
+ if (result.code === 0 && m) return res.writeHead(200, { 'content-type': 'application/json' })
+ .end(JSON.stringify({ ok: true, id: m[1], watch: 'https://youtu.be/' + m[1], privacy: 'unlisted' }));
+ return res.writeHead(502, { 'content-type': 'application/json' })
+ .end(JSON.stringify({ ok: false, error: (result.out || 'upload failed').trim().slice(-400) }));
+ } catch (e) { building = false; return res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: String(e) })); }
+ }
// authed serve of any rendered mp4 under videos/ or reels/ (traversal-guarded)
if (p.startsWith('/studio-media/')) {
const rel = decodeURIComponent(p.slice('/studio-media/'.length));
← 2165410 reels: prune 3 dead 2026-07-13 entries whose mp4s never rend
·
back to Dw Marketing Reels
·
Wire all 35 postable IG accounts as ready in reels console ( 5363df3 →