← back to Nineoh Guide

db/ingest-tvmaze.mjs

85 lines

// Ingests FACTUAL episode metadata only (title, season, episode number, air date)
// from the free TVmaze API — NOT synopses (those stay original editorial work).
// TVmaze is credited via source_type='tvmaze' + external_ids.tvmaze. Rate limit is
// generous (>=20 req / 10s); this makes a single embedded call, so we're well under.
// Run: node db/ingest-tvmaze.mjs
import pg from "pg";

const DATABASE_URL =
  process.env.DATABASE_URL ?? "postgresql://localhost/nineoh_guide?host=/tmp";
const pool = new pg.Pool({ connectionString: DATABASE_URL });

// Find the 2008 CW series specifically (not the 1990 original).
const searchRes = await fetch("https://api.tvmaze.com/search/shows?q=90210");
const results = await searchRes.json();
const pick =
  results
    .map((r) => r.show)
    .find(
      (s) =>
        s.name === "90210" &&
        (s.premiered ?? "").startsWith("2008")
    ) ?? results[0]?.show;

if (!pick) {
  console.error("No TVmaze match for 90210");
  process.exit(1);
}
console.log(`TVmaze show: ${pick.name} (${pick.premiered}) id=${pick.id} — ${pick.network?.name ?? "?"}`);

const show = (await pool.query(`select id from shows where canonical_title='90210'`)).rows[0];
if (!show) {
  console.error("Run db/seed.mjs first to create the show row.");
  process.exit(1);
}
const showId = show.id;

// One embedded call returns every episode (factual fields only).
const epsRes = await fetch(`https://api.tvmaze.com/shows/${pick.id}?embed=episodes`);
const data = await epsRes.json();
const episodes = data._embedded?.episodes ?? [];

// Upsert seasons.
const seasonNums = [...new Set(episodes.map((e) => e.season))].sort((a, b) => a - b);
for (const n of seasonNums) {
  const count = episodes.filter((e) => e.season === n).length;
  await pool.query(
    `insert into seasons (show_id, season_number, episode_count, source_last_verified_at)
     values ($1,$2,$3, now())
     on conflict (show_id, season_number)
     do update set episode_count = excluded.episode_count, source_last_verified_at = now()`,
    [showId, n, count]
  );
}

// Upsert episodes — FACTUAL fields only; summaries left NULL for editorial recaps.
let inserted = 0;
for (const e of episodes) {
  const seasonRow = (
    await pool.query(`select id from seasons where show_id=$1 and season_number=$2`, [showId, e.season])
  ).rows[0];
  const res = await pool.query(
    `insert into episodes
       (show_id, season_id, season_number, episode_number, title, air_date,
        source_url, source_type, external_ids, last_verified_at)
     values ($1,$2,$3,$4,$5,$6,$7,'tvmaze',$8, now())
     on conflict (show_id, season_number, episode_number)
     do update set title = excluded.title, air_date = excluded.air_date, last_verified_at = now()`,
    [
      showId,
      seasonRow?.id ?? null,
      e.season,
      e.number,
      e.name,
      e.airdate || null,
      e.url || null,
      JSON.stringify({ tvmaze: String(e.id) }),
    ]
  );
  inserted += res.rowCount;
}

console.log(`seasons: ${seasonNums.length}, episodes upserted: ${inserted}`);
console.log("Recaps intentionally left NULL — original editorial recaps are a separate task.");
await pool.end();