[object Object]

← back to Stars of Design

Add auto-updating All News Daily embedded Shorts section to /videos

8ffbfd96fa23bb3890e0ee80c823fc22b3ef5c63 · 2026-09-09 23:41:47 -0700 · Steve Abrams

- scripts/sync-allnewsdaily-videos.mjs pulls the channel uploads via the
  YouTube Data API (raw fetch, refresh-token flow) and writes
  data/allnewsdaily-videos.json, filtered to our 'All News Daily —' Shorts
- lib/data.js loadAllNewsDaily() (mtime-cache, missing-file -> [])
- /videos route passes allNewsDaily to the template
- videos.ejs renders a new top section of 9:16 embedded iframe players;
  hides gracefully when empty. Existing 75-video design gallery unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8ffbfd96fa23bb3890e0ee80c823fc22b3ef5c63
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 23:41:47 2026 -0700

    Add auto-updating All News Daily embedded Shorts section to /videos
    
    - scripts/sync-allnewsdaily-videos.mjs pulls the channel uploads via the
      YouTube Data API (raw fetch, refresh-token flow) and writes
      data/allnewsdaily-videos.json, filtered to our 'All News Daily —' Shorts
    - lib/data.js loadAllNewsDaily() (mtime-cache, missing-file -> [])
    - /videos route passes allNewsDaily to the template
    - videos.ejs renders a new top section of 9:16 embedded iframe players;
      hides gracefully when empty. Existing 75-video design gallery unchanged.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 data/allnewsdaily-videos.json        |   7 ++
 lib/data.js                          |  32 ++++++
 public/css/site.css                  |  35 +++++++
 routes/public.js                     |   3 +
 scripts/sync-allnewsdaily-videos.mjs | 197 +++++++++++++++++++++++++++++++++++
 views/public/videos.ejs              |  37 +++++++
 6 files changed, 311 insertions(+)

diff --git a/data/allnewsdaily-videos.json b/data/allnewsdaily-videos.json
new file mode 100644
index 0000000..5d1959d
--- /dev/null
+++ b/data/allnewsdaily-videos.json
@@ -0,0 +1,7 @@
+[
+  {
+    "videoId": "JRcJ5SInd48",
+    "title": "All News Daily — September 10: Trump claims he will provide $5,000 to each American adul…",
+    "publishedAt": "2026-09-10T05:45:17Z"
+  }
+]
diff --git a/lib/data.js b/lib/data.js
index f9ce32d..834dbe1 100644
--- a/lib/data.js
+++ b/lib/data.js
@@ -142,7 +142,39 @@ function filterVideos(opts = {}) {
   return rows;
 }
 
+// ── All News Daily: embedded YouTube Shorts (data/allnewsdaily-videos.json) ──
+// Written by scripts/sync-allnewsdaily-videos.mjs — [{videoId, title, publishedAt}].
+// Guard against a missing file (returns []) and re-derive the embed URL from the
+// video id to be defensive — never trust the raw JSON id for the iframe src.
+const ALLNEWS_FILE = path.join(__dirname, '..', 'data', 'allnewsdaily-videos.json');
+let _allNews = null;
+let _allNewsMtime = 0;
+
+function loadAllNewsDaily() {
+  try {
+    const stat = fs.statSync(ALLNEWS_FILE);
+    if (!_allNews || stat.mtimeMs !== _allNewsMtime) {
+      const raw = JSON.parse(fs.readFileSync(ALLNEWS_FILE, 'utf8'));
+      _allNews = (Array.isArray(raw) ? raw : [])
+        .filter(v => v && /^[A-Za-z0-9_-]{11}$/.test(v.videoId))
+        .map(v => ({
+          videoId: v.videoId,
+          title: v.title || '',
+          publishedAt: v.publishedAt || '',
+          embed_url: `https://www.youtube.com/embed/${v.videoId}`,
+          watch_url: `https://www.youtube.com/watch?v=${v.videoId}`,
+        }));
+      _allNewsMtime = stat.mtimeMs;
+    }
+  } catch (e) {
+    if (e.code === 'ENOENT') return [];
+    return [];  // best-effort: a malformed file should never break /videos
+  }
+  return _allNews || [];
+}
+
 module.exports = {
   load, bySlug, filter, allStyles, allEras,
   loadVideos, videosByDesigner, videoCategories, videoSources, filterVideos,
+  loadAllNewsDaily,
 };
diff --git a/public/css/site.css b/public/css/site.css
index 736d88b..076ae61 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -394,6 +394,41 @@ html[data-theme='dark']  .theme-toggle-sun  { display: none; }
 .sources li a, .links li a { color: var(--link); }
 .sources li em { color: var(--text-muted); font-style: italic; }
 
+/* All News Daily — embedded 9:16 Shorts section (top of /videos) */
+.and-section { border-bottom: 1px solid var(--border); }
+.and-grid {
+  display: grid;
+  /* Shorts are vertical (9:16) — narrower cards so the tall players tile nicely */
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 1.25rem;
+}
+.and-card {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius);
+  overflow: hidden;
+  box-shadow: var(--shadow);
+  display: flex; flex-direction: column;
+}
+.and-embed {
+  position: relative;
+  aspect-ratio: 9 / 16;      /* vertical Short */
+  background: #0a0a0a;
+}
+.and-embed iframe {
+  position: absolute; inset: 0;
+  width: 100%; height: 100%;
+  border: 0;
+}
+.and-card-body { padding: .8rem 1rem 1rem; flex: 1; display: flex; flex-direction: column; gap: .25rem; }
+.and-card-body h3 {
+  font-family: var(--font-serif); font-size: .98rem; line-height: 1.3;
+  margin: 0; font-weight: 500;
+  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
+  overflow: hidden; text-overflow: ellipsis;
+}
+.and-card-body h3 a { color: var(--text); }
+
 /* Video gallery — /videos page */
 .video-grid {
   display: grid;
diff --git a/routes/public.js b/routes/public.js
index e1bbb0c..5308168 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -649,6 +649,8 @@ router.get('/videos', (req, res, next) => {
       q:          req.query.q,
     };
     const rows = data.filterVideos(filt);
+    let allNewsDaily = [];
+    try { allNewsDaily = data.loadAllNewsDaily(); } catch (_) { allNewsDaily = []; }
     res.render('public/videos', {
       title: 'Designer Videos — Home Tours, Interviews & Project Showcases | Stars of Design',
       meta_desc_override: 'Curated YouTube video gallery of US interior designer home tours, sit-down interviews, and project showcases from Architectural Digest, House Beautiful, Studio McGee, and the firms themselves. Cross-linked to the Stars of Design directory.',
@@ -657,6 +659,7 @@ router.get('/videos', (req, res, next) => {
       filter: filt,
       categories: data.videoCategories(),
       sources:    data.videoSources(),
+      allNewsDaily,
     });
   } catch (e) { next(e); }
 });
diff --git a/scripts/sync-allnewsdaily-videos.mjs b/scripts/sync-allnewsdaily-videos.mjs
new file mode 100644
index 0000000..93cbd13
--- /dev/null
+++ b/scripts/sync-allnewsdaily-videos.mjs
@@ -0,0 +1,197 @@
+#!/usr/bin/env node
+// sync-allnewsdaily-videos.mjs — pull the All News Daily YouTube channel's uploads
+// and write data/allnewsdaily-videos.json for the starsofdesign /videos page.
+//
+// Node v26, raw fetch, NO googleapis dependency.
+//
+// Flow:
+//   1. Refresh an access token (POST https://oauth2.googleapis.com/token)
+//        - refresh token   : ~/Projects/allnewsdaily/.env         (YOUTUBE_REFRESH_TOKEN=)
+//        - client id/secret: ~/Projects/secrets-manager/.env       (YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET)
+//   2. channels.list?part=contentDetails&mine=true → relatedPlaylists.uploads
+//   3. Page playlistItems?part=snippet over the uploads playlist
+//   4. FILTER to our Shorts only: snippet.title starts with "All News Daily —"
+//   5. Write [{videoId, title, publishedAt}] newest-first, cap ~24, to
+//        ~/Projects/starsofdesign/data/allnewsdaily-videos.json
+//
+// Best-effort: if the API fails, leave any existing allnewsdaily-videos.json
+// intact; if none exists, write [].
+//
+// Usage: node scripts/sync-allnewsdaily-videos.mjs
+
+import { readFileSync, writeFileSync, existsSync } 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 SECRETS_ENV = join(homedir(), 'Projects', 'secrets-manager', '.env');
+const ALLNEWS_ENV = join(homedir(), 'Projects', 'allnewsdaily', '.env');
+const OUT_FILE = join(__dirname, '..', 'data', 'allnewsdaily-videos.json');
+
+const CHANNEL_ID = 'UCr2NrTNDW_iDWvpNX0Txprg';   // All News Daily (informational; we resolve uploads via mine=true)
+const TITLE_PREFIX = 'All News Daily —';         // our upload-title format → keeps this section unique to our Shorts
+const CAP = 24;
+
+const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
+
+// --- 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 app = parseEnv(ALLNEWS_ENV);
+  const clientId = secrets.YOUTUBE_CLIENT_ID || process.env.YOUTUBE_CLIENT_ID;
+  const clientSecret = secrets.YOUTUBE_CLIENT_SECRET || process.env.YOUTUBE_CLIENT_SECRET;
+  const refreshToken = app.YOUTUBE_REFRESH_TOKEN || process.env.YOUTUBE_REFRESH_TOKEN;
+  return { clientId, clientSecret, refreshToken };
+}
+
+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 ytGet(url, accessToken) {
+  const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
+  const json = await res.json().catch(() => ({}));
+  if (!res.ok) {
+    throw new Error(`GET ${url} → HTTP ${res.status}: ${JSON.stringify(json).slice(0, 300)}`);
+  }
+  return json;
+}
+
+async function getUploadsPlaylistId(accessToken) {
+  const json = await ytGet(
+    'https://www.googleapis.com/youtube/v3/channels?part=contentDetails&mine=true',
+    accessToken
+  );
+  const item = json.items && json.items[0];
+  const uploads = item?.contentDetails?.relatedPlaylists?.uploads;
+  if (!uploads) throw new Error('no uploads playlist found for authorized channel');
+  return uploads;
+}
+
+async function getAllUploads(playlistId, accessToken) {
+  const items = [];
+  let pageToken = '';
+  // Page until exhausted OR we've comfortably collected enough matches.
+  for (let guard = 0; guard < 20; guard++) {
+    const url = new URL('https://www.googleapis.com/youtube/v3/playlistItems');
+    url.searchParams.set('part', 'snippet');
+    url.searchParams.set('playlistId', playlistId);
+    url.searchParams.set('maxResults', '50');
+    if (pageToken) url.searchParams.set('pageToken', pageToken);
+    const json = await ytGet(url.toString(), accessToken);
+    for (const it of json.items || []) {
+      const s = it.snippet || {};
+      const videoId = s.resourceId?.videoId;
+      if (!videoId) continue;
+      items.push({ videoId, title: s.title || '', publishedAt: s.publishedAt || '' });
+    }
+    pageToken = json.nextPageToken || '';
+    if (!pageToken) break;
+  }
+  return items;
+}
+
+function readExisting() {
+  try {
+    if (!existsSync(OUT_FILE)) return null;
+    const parsed = JSON.parse(readFileSync(OUT_FILE, 'utf8'));
+    return Array.isArray(parsed) ? parsed : null;
+  } catch (_) {
+    return null;
+  }
+}
+
+async function main() {
+  const creds = loadCreds();
+  if (!creds.clientId || !creds.clientSecret) {
+    console.error(`[sync-and] Missing YOUTUBE_CLIENT_ID / YOUTUBE_CLIENT_SECRET in ${SECRETS_ENV}`);
+    return bestEffortExit();
+  }
+  if (!creds.refreshToken) {
+    console.error(`[sync-and] Missing YOUTUBE_REFRESH_TOKEN in ${ALLNEWS_ENV}`);
+    return bestEffortExit();
+  }
+
+  try {
+    const accessToken = await refreshAccessToken(creds);
+    const uploadsPlaylist = await getUploadsPlaylistId(accessToken);
+    const all = await getAllUploads(uploadsPlaylist, accessToken);
+
+    // Filter to OUR Shorts (title prefix), newest-first, cap.
+    const filtered = all
+      .filter(v => v.title.startsWith(TITLE_PREFIX))
+      .sort((a, b) => (b.publishedAt || '').localeCompare(a.publishedAt || ''))
+      .slice(0, CAP)
+      .map(v => ({ videoId: v.videoId, title: v.title, publishedAt: v.publishedAt }));
+
+    console.log(
+      `[sync-and] channel uploads: ${all.length}, matching "${TITLE_PREFIX}": ${filtered.length} (cap ${CAP})`
+    );
+
+    writeFileSync(OUT_FILE, JSON.stringify(filtered, null, 2) + '\n');
+    console.log(`[sync-and] wrote ${filtered.length} video(s) → ${OUT_FILE}`);
+    process.exit(0);
+  } catch (e) {
+    console.error(`[sync-and] API failed: ${e.message}`);
+    return bestEffortExit();
+  }
+}
+
+// Best-effort: preserve any existing file; if none, write [] so the template
+// has something well-formed to load.
+function bestEffortExit() {
+  const existing = readExisting();
+  if (existing) {
+    console.error(`[sync-and] leaving existing ${OUT_FILE} intact (${existing.length} video(s))`);
+    process.exit(1);
+  }
+  writeFileSync(OUT_FILE, '[]\n');
+  console.error(`[sync-and] no existing file — wrote empty [] → ${OUT_FILE}`);
+  process.exit(1);
+}
+
+main().catch((e) => {
+  console.error(`[sync-and] Fatal: ${e.message}`);
+  bestEffortExit();
+});
diff --git a/views/public/videos.ejs b/views/public/videos.ejs
index cfe5f35..2136d04 100644
--- a/views/public/videos.ejs
+++ b/views/public/videos.ejs
@@ -2,6 +2,43 @@
 <%- include('../partials/header') %>
 
 <main class="videos-page">
+  <% var andVideos = (typeof allNewsDaily !== 'undefined' && Array.isArray(allNewsDaily)) ? allNewsDaily : []; %>
+  <% if (andVideos.length) { %>
+  <section class="section and-section">
+    <div class="wrap">
+      <header class="rail-header" style="display:block">
+        <h1>All News Daily</h1>
+        <p class="muted" style="max-width:760px;margin:.5rem 0 1.25rem">
+          The latest short-form news briefings from the <strong>All News Daily</strong> channel — daily headline recaps, embedded and playable right here. New Shorts appear automatically as they publish.
+        </p>
+      </header>
+
+      <div class="and-grid">
+        <% andVideos.forEach(function (v) { %>
+          <article class="and-card">
+            <div class="and-embed">
+              <iframe
+                src="<%= v.embed_url %>"
+                title="<%= v.title %>"
+                loading="lazy"
+                frameborder="0"
+                allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+                referrerpolicy="strict-origin-when-cross-origin"
+                allowfullscreen></iframe>
+            </div>
+            <div class="and-card-body">
+              <h3><a href="<%= v.watch_url %>" target="_blank" rel="noopener noreferrer"><%= v.title %></a></h3>
+              <% if (v.publishedAt) { %>
+                <p class="muted small"><%= new Date(v.publishedAt).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' }) %></p>
+              <% } %>
+            </div>
+          </article>
+        <% }); %>
+      </div>
+    </div>
+  </section>
+  <% } %>
+
   <section class="section">
     <div class="wrap">
       <header class="rail-header" style="display:block">

← 572966b TK-11341: add AdSense Auto-Ads loader + ads.txt (revert to r  ·  back to Stars of Design  ·  Mac2 sync-and-push job for the /videos All News Daily sectio 88cd92f →