← 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}`);
});