← 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
A data/allnewsdaily-videos.jsonM lib/data.jsM public/css/site.cssM routes/public.jsA scripts/sync-allnewsdaily-videos.mjsM views/public/videos.ejs
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 →