← 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
M .deploy.confM .gitignoreA scripts/sync-videos.mjsM server.js
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. © ${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. © ${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 →