[object Object]

← back to Allnewsdaily

allnewsdaily: add /videos page embedding channel Shorts + sync-videos.mjs

3585c9c7b1b0e457851b8011ac14bfc2fb7928d5 · 2026-09-10 00:01:50 -0700 · Steve Abrams

Files touched

Diff

commit 3585c9c7b1b0e457851b8011ac14bfc2fb7928d5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 00:01:50 2026 -0700

    allnewsdaily: add /videos page embedding channel Shorts + sync-videos.mjs
---
 .deploy.conf            |   2 +
 .gitignore              |   1 +
 scripts/sync-videos.mjs | 229 ++++++++++++++++++++++++++++++++++++++++++++++++
 server.js               |  96 +++++++++++++++++++-
 4 files changed, 325 insertions(+), 3 deletions(-)

diff --git a/.deploy.conf b/.deploy.conf
index 3068eee..517a619 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -1,3 +1,5 @@
 PROJECT_NAME=allnewsdaily
 DEPLOY_PATH=/root/Projects/allnewsdaily
 HEALTH_URL=http://127.0.0.1:9962/api/health
+# Runtime data is rebuilt on prod by the live-push/short crons — never ship local snapshots over it.
+RSYNC_EXTRA_EXCLUDES="/data/wire.json /data/live-status.json /data/live-static.flag /data/paraphrase-cache.json /data/short/ /data/videos.json"
diff --git a/.gitignore b/.gitignore
index 489c960..9b78aa9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ data/paraphrase-cache.json
 data/wire.json
 data/live-static.flag
 data/short/
+data/videos.json
diff --git a/scripts/sync-videos.mjs b/scripts/sync-videos.mjs
new file mode 100644
index 0000000..82ea5a1
--- /dev/null
+++ b/scripts/sync-videos.mjs
@@ -0,0 +1,229 @@
+#!/usr/bin/env node
+// sync-videos.mjs — pull the All News Daily YouTube channel's uploads and write
+// data/videos.json as [{videoId, title, publishedAt}] (newest first, cap 30).
+//
+// These are the daily Shorts produced by scripts/short/. The /videos page on the
+// site (server.js GET /videos) reads data/videos.json and embeds each as a 9:16
+// player. On prod (Kamatera) videos.json is a runtime snapshot pushed Mac2→Kamatera
+// like wire.json (excluded from the deploy rsync), so run this on Mac2.
+//
+// Node v26, raw fetch, NO googleapis dependency.
+//
+// Auth:
+//   - refresh token:  ~/Projects/allnewsdaily/.env       YOUTUBE_REFRESH_TOKEN=
+//   - client id/secret: ~/Projects/secrets-manager/.env  YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET
+//
+// Flow: refresh an access token → channels.list(mine=true, part=contentDetails)
+//       → relatedPlaylists.uploads → page playlistItems(part=snippet) → filter to
+//       titles starting with "All News Daily —" → newest first, cap 30.
+//
+// Best-effort: on ANY API failure, PRESERVE an existing data/videos.json; if none
+// exists, write []. Never leaves the page without a valid JSON array.
+//
+// Usage: node scripts/sync-videos.mjs
+
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const APP_DIR = join(__dirname, '..');
+const SECRETS_ENV = join(homedir(), 'Projects', 'secrets-manager', '.env');
+const APP_ENV = join(APP_DIR, '.env');
+const OUT_PATH = join(APP_DIR, 'data', 'videos.json');
+
+const CHANNEL_ID = 'UCr2NrTNDW_iDWvpNX0Txprg';
+const TITLE_PREFIX = 'All News Daily —'; // our upload-title format (em dash)
+const CAP = 30;
+
+const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
+const CHANNELS_ENDPOINT =
+  'https://www.googleapis.com/youtube/v3/channels?part=contentDetails&mine=true';
+const PLAYLIST_ITEMS_ENDPOINT =
+  'https://www.googleapis.com/youtube/v3/playlistItems';
+
+// --- tiny .env parser (no dotenv dependency) -------------------------------
+function parseEnv(path) {
+  const out = {};
+  if (!existsSync(path)) return out;
+  const txt = readFileSync(path, 'utf8');
+  for (const raw of txt.split('\n')) {
+    const line = raw.trim();
+    if (!line || line.startsWith('#')) continue;
+    const eq = line.indexOf('=');
+    if (eq === -1) continue;
+    const key = line.slice(0, eq).trim();
+    let val = line.slice(eq + 1).trim();
+    if (
+      (val.startsWith('"') && val.endsWith('"')) ||
+      (val.startsWith("'") && val.endsWith("'"))
+    ) {
+      val = val.slice(1, -1);
+    }
+    out[key] = val;
+  }
+  return out;
+}
+
+function loadCreds() {
+  const secrets = parseEnv(SECRETS_ENV);
+  const appEnv = parseEnv(APP_ENV);
+  return {
+    clientId: secrets.YOUTUBE_CLIENT_ID || process.env.YOUTUBE_CLIENT_ID,
+    clientSecret:
+      secrets.YOUTUBE_CLIENT_SECRET || process.env.YOUTUBE_CLIENT_SECRET,
+    refreshToken:
+      appEnv.YOUTUBE_REFRESH_TOKEN || process.env.YOUTUBE_REFRESH_TOKEN,
+  };
+}
+
+// Best-effort writer: never overwrite good data with nothing.
+function preserveOrEmpty(reason) {
+  if (existsSync(OUT_PATH)) {
+    console.warn(`[sync-videos] ${reason} — preserving existing ${OUT_PATH}`);
+    return 'preserved';
+  }
+  ensureDir();
+  writeFileSync(OUT_PATH, '[]\n');
+  console.warn(`[sync-videos] ${reason} — no prior snapshot, wrote [] to ${OUT_PATH}`);
+  return 'empty';
+}
+
+function ensureDir() {
+  const dir = dirname(OUT_PATH);
+  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
+}
+
+async function refreshAccessToken({ clientId, clientSecret, refreshToken }) {
+  const body = new URLSearchParams({
+    client_id: clientId,
+    client_secret: clientSecret,
+    refresh_token: refreshToken,
+    grant_type: 'refresh_token',
+  });
+  const res = await fetch(TOKEN_ENDPOINT, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: body.toString(),
+  });
+  const json = await res.json().catch(() => ({}));
+  if (!res.ok || !json.access_token) {
+    throw new Error(
+      `token refresh HTTP ${res.status}: ${json.error || ''} ${json.error_description || ''}`.trim()
+    );
+  }
+  return json.access_token;
+}
+
+async function getUploadsPlaylistId(accessToken) {
+  const res = await fetch(CHANNELS_ENDPOINT, {
+    headers: { Authorization: `Bearer ${accessToken}` },
+  });
+  const json = await res.json().catch(() => ({}));
+  if (!res.ok) {
+    throw new Error(
+      `channels.list HTTP ${res.status}: ${JSON.stringify(json).slice(0, 200)}`
+    );
+  }
+  const item = json.items && json.items[0];
+  const uploads =
+    item &&
+    item.contentDetails &&
+    item.contentDetails.relatedPlaylists &&
+    item.contentDetails.relatedPlaylists.uploads;
+  if (!uploads) throw new Error('no uploads playlist on authorized channel');
+  // Sanity: the authorized channel should be ours.
+  if (item.id && item.id !== CHANNEL_ID) {
+    console.warn(
+      `[sync-videos] WARNING: authorized channel ${item.id} != expected ${CHANNEL_ID}`
+    );
+  }
+  return uploads;
+}
+
+async function pagePlaylistItems(accessToken, playlistId) {
+  const items = [];
+  let pageToken = '';
+  // Cap the pages we fetch; we only need enough to fill CAP after filtering.
+  for (let page = 0; page < 5; page++) {
+    const url = new URL(PLAYLIST_ITEMS_ENDPOINT);
+    url.searchParams.set('part', 'snippet');
+    url.searchParams.set('playlistId', playlistId);
+    url.searchParams.set('maxResults', '50');
+    if (pageToken) url.searchParams.set('pageToken', pageToken);
+    const res = await fetch(url, {
+      headers: { Authorization: `Bearer ${accessToken}` },
+    });
+    const json = await res.json().catch(() => ({}));
+    if (!res.ok) {
+      throw new Error(
+        `playlistItems HTTP ${res.status}: ${JSON.stringify(json).slice(0, 200)}`
+      );
+    }
+    for (const it of json.items || []) {
+      const sn = it.snippet || {};
+      const videoId =
+        sn.resourceId && sn.resourceId.videoId ? sn.resourceId.videoId : null;
+      if (!videoId) continue;
+      items.push({
+        videoId,
+        title: sn.title || '',
+        publishedAt: sn.publishedAt || '',
+      });
+    }
+    pageToken = json.nextPageToken || '';
+    if (!pageToken) break;
+  }
+  return items;
+}
+
+async function main() {
+  const creds = loadCreds();
+  if (!creds.clientId || !creds.clientSecret) {
+    preserveOrEmpty('missing YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET in secrets .env');
+    return;
+  }
+  if (!creds.refreshToken) {
+    preserveOrEmpty('missing YOUTUBE_REFRESH_TOKEN in app .env (run scripts/short/youtube-auth.mjs)');
+    return;
+  }
+
+  let videos;
+  try {
+    const accessToken = await refreshAccessToken(creds);
+    const playlistId = await getUploadsPlaylistId(accessToken);
+    const raw = await pagePlaylistItems(accessToken, playlistId);
+
+    // Filter to our upload-title format, newest first, cap.
+    videos = raw
+      .filter((v) => v.title && v.title.startsWith(TITLE_PREFIX))
+      .sort((a, b) => {
+        // publishedAt is ISO 8601; string compare works, but be defensive.
+        const ta = Date.parse(a.publishedAt) || 0;
+        const tb = Date.parse(b.publishedAt) || 0;
+        return tb - ta;
+      })
+      .slice(0, CAP)
+      .map((v) => ({
+        videoId: v.videoId,
+        title: v.title,
+        publishedAt: v.publishedAt,
+      }));
+  } catch (e) {
+    preserveOrEmpty(`API failure: ${e.message}`);
+    return;
+  }
+
+  ensureDir();
+  writeFileSync(OUT_PATH, JSON.stringify(videos, null, 2) + '\n');
+  console.log(
+    `[sync-videos] wrote ${videos.length} video(s) → ${OUT_PATH}` +
+      (videos[0] ? ` (newest: ${videos[0].videoId} "${videos[0].title}")` : '')
+  );
+}
+
+main().catch((e) => {
+  // Last-ditch guard — even an unexpected throw must not blank a good snapshot.
+  preserveOrEmpty(`unexpected error: ${e.message}`);
+});
diff --git a/server.js b/server.js
index 5e724b3..19f00d6 100644
--- a/server.js
+++ b/server.js
@@ -121,7 +121,7 @@ function renderFront() {
   <h1 class="wordmark"><a href="/">ALL NEWS DAILY</a></h1>
   <div class="tagline-r">${esc(today)}</div>
 </header>
-<div class="updated">${esc(updated)} · <a href="/directory">outlet directory</a> · <a href="/guides">guides</a></div>
+<div class="updated">${esc(updated)} · <a href="/directory">outlet directory</a> · <a href="/guides">guides</a> · <a href="/videos">videos</a></div>
 <div class="ticker" id="ticker" aria-live="polite">
   <span class="tk-live"><span class="tk-blink"></span> LIVE WIRE</span>
   <span class="tk-seg">Next refresh in <b id="tk-count">2:00</b></span>
@@ -206,7 +206,7 @@ ${bannerAd('top')}
 </script>
 
 <footer class="foot">
-  <nav><a href="/directory">Directory</a> · <a href="/guides">Guides</a> · <a href="/about">About</a> · <a href="/contact">Contact</a> · <a href="/privacy">Privacy</a></nav>
+  <nav><a href="/directory">Directory</a> · <a href="/guides">Guides</a> · <a href="/videos">Videos</a> · <a href="/about">About</a> · <a href="/contact">Contact</a> · <a href="/privacy">Privacy</a></nav>
   <p>Topic lines are original one-sentence summaries written by All News Daily; every link goes to the source outlet's own reporting. All News Daily does not republish articles. &copy; ${new Date().getFullYear()} All News Daily.</p>
 </footer>
 </body></html>`;
@@ -216,6 +216,84 @@ app.get('/', (req, res) => {
   res.type('html').send(renderFront());
 });
 
+// ---- Video Briefings (embedded All News Daily YouTube Shorts) --------------
+const VIDEOS_PATH = path.join(__dirname, 'data', 'videos.json');
+function loadVideos() {
+  try {
+    const arr = JSON.parse(fs.readFileSync(VIDEOS_PATH, 'utf8'));
+    return Array.isArray(arr) ? arr : [];
+  } catch (_) {
+    return [];
+  }
+}
+// A valid YouTube videoId is 11 chars of [A-Za-z0-9_-]. Never embed anything else.
+const VALID_VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
+
+function renderVideos() {
+  const today = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/New_York' });
+  const fmtDate = (iso) => {
+    try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/New_York' }); }
+    catch (_) { return ''; }
+  };
+  const videos = loadVideos().filter((v) => v && VALID_VIDEO_ID.test(v.videoId || ''));
+
+  const cardsHtml = videos.map((v) => {
+    const id = v.videoId; // regex-validated above — safe to interpolate into the embed URL
+    const title = esc(v.title || 'All News Daily briefing');
+    const when = v.publishedAt ? `<div class="vmeta">${esc(fmtDate(v.publishedAt))}</div>` : '';
+    return `<figure class="vcard">
+      <div class="vframe">
+        <iframe src="https://www.youtube.com/embed/${id}" title="${title}"
+          loading="lazy" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+          referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
+      </div>
+      <figcaption class="vcap">${title}${when}</figcaption>
+    </figure>`;
+  }).join('\n');
+
+  const gridHtml = videos.length
+    ? `<div class="vgrid">${cardsHtml}</div>`
+    : `<div class="vempty">Briefings publishing soon — check back shortly.</div>`;
+
+  return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Video Briefings — All News Daily</title>
+<meta name="description" content="Video Briefings from All News Daily — short daily video rundowns of the world's top headlines, straight from the All News Daily YouTube channel.">
+<link rel="canonical" href="https://allnewsdaily.com/videos">
+<meta property="og:type" content="website"><meta property="og:site_name" content="All News Daily">
+<meta property="og:title" content="Video Briefings — All News Daily">
+<meta property="og:description" content="Short daily video rundowns of the world's top headlines from All News Daily.">
+<link rel="icon" href="/static/favicon.svg">
+<style>${FRONT_CSS}${VIDEOS_CSS}</style>${ADSENSE}</head>
+<body>
+<header class="masthead">
+  <div class="tagline-l">EST. 2026 · GLOBAL WIRE</div>
+  <h1 class="wordmark"><a href="/">ALL NEWS DAILY</a></h1>
+  <div class="tagline-r">${esc(today)}</div>
+</header>
+<div class="updated"><a href="/">front page</a> · <a href="/directory">outlet directory</a> · <a href="/guides">guides</a> · <a href="/videos">videos</a></div>
+<hr class="rule">
+${bannerAd('top')}
+
+<main>
+  <h2 class="vhead">Video Briefings</h2>
+  <p class="vlede">Short daily video rundowns of the day's top headlines, from the All News Daily YouTube channel.</p>
+  <hr class="rule thin">
+  ${gridHtml}
+  ${bannerAd('bottom')}
+</main>
+
+<footer class="foot">
+  <nav><a href="/">Front page</a> · <a href="/directory">Directory</a> · <a href="/guides">Guides</a> · <a href="/videos">Videos</a> · <a href="/about">About</a> · <a href="/contact">Contact</a> · <a href="/privacy">Privacy</a></nav>
+  <p>Video Briefings are produced by All News Daily and published on our YouTube channel. &copy; ${new Date().getFullYear()} All News Daily.</p>
+</footer>
+</body></html>`;
+}
+
+app.get('/videos', (req, res) => {
+  res.type('html').send(renderVideos());
+});
+
 // Original outlet directory (live TV/broadcast grid) preserved here.
 app.get('/directory', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'index.html'));
@@ -227,7 +305,7 @@ app.get('/robots.txt', (_q, res) => res.type('text/plain').send('User-agent: *\n
 
 app.get('/sitemap.xml', (_q, res) => {
   const B = 'https://allnewsdaily.com';
-  const urls = ['/', '/directory', '/guides', '/about', '/contact', '/privacy']
+  const urls = ['/', '/directory', '/guides', '/videos', '/about', '/contact', '/privacy']
     .concat(GUIDES.map((g) => `/guides/${g.slug}`))
     .map((u) => `  <url><loc>${B}${u}</loc></url>`).join('\n');
   res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`);
@@ -309,6 +387,18 @@ main{max-width:1180px;margin:0 auto;padding:0 12px 60px}
 .ad-house-brand{font:800 22px/1 'Times New Roman',Times,serif;letter-spacing:1.5px;color:#111;text-transform:uppercase}
 .ad-house-sub{font:12px/1 Arial,Helvetica,sans-serif;color:#8a8779}
 `;
+// /videos page — reuses FRONT_CSS (masthead/wordmark/ad zones/footer) + this grid of 9:16 embeds.
+const VIDEOS_CSS = `
+.vhead{text-align:center;font-family:'Times New Roman',Times,serif;text-transform:uppercase;letter-spacing:1px;font-size:clamp(22px,3.4vw,32px);margin:14px 0 4px}
+.vlede{text-align:center;color:#444;font-size:14px;margin:0 auto 6px;max-width:640px}
+.vgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:22px 20px;padding:14px 0 8px}
+.vcard{margin:0}
+.vframe{position:relative;aspect-ratio:9/16;background:#000;border:1px solid #cbc9bf;overflow:hidden}
+.vframe iframe{position:absolute;inset:0;width:100%;height:100%;border:0;display:block}
+.vcap{font-size:13px;line-height:1.32;padding:7px 2px 0;color:#111}
+.vmeta{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--src);margin-top:3px}
+.vempty{text-align:center;color:#888;font-style:italic;padding:48px 16px;border:1px dashed #cbc9bf;background:#efeee7;margin:14px 0}
+`;
 const GUIDE_CSS = `body{margin:0;background:#0d1117;color:#e6edf3;font:17px/1.7 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
 a{color:#58a6ff}.wrap{max-width:760px;margin:0 auto;padding:28px 22px 80px}
 .top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #21262d;padding-bottom:14px;margin-bottom:26px}

← 85e4126 front page: Drudge-style 3-column article layout  ·  back to Allnewsdaily  ·  allnewsdaily /videos: Mac2 sync-and-push job for data/videos bf3c145 →