← back to Allnewsdaily
scripts/sync-videos.mjs
230 lines
#!/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}`);
});