| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172 |
- import * as cron from "node-cron";
- import { createClient } from "@supabase/supabase-js";
- const TMDB_API_BASE_URL = "https://api.themoviedb.org/3";
- const REEL_POSTER_COUNT = 20;
- // Mirror of PINNED_REEL_POSTERS in src/app/api/tmdb/reel-posters/route.ts.
- // Keep in sync if either side changes.
- const PINNED_REEL_POSTERS: ReelPoster[] = [
- {
- tmdb_id: 615,
- title: "The Passion of the Christ",
- poster_path: "/rBM5o2HpmCfDejuIPybI09tkY3V.jpg",
- },
- ];
- interface ReelPoster {
- tmdb_id: number;
- poster_path: string;
- title: string;
- }
- interface TMDBMovie {
- id: number;
- title: string;
- poster_path: string | null;
- adult: boolean;
- }
- interface TMDBPopularResponse {
- results: TMDBMovie[];
- }
- const SUPABASE_URL = process.env.SUPABASE_URL;
- const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
- const TMDB_API_KEY = process.env.TMDB_API_KEY;
- function requireEnv(): { supabaseUrl: string; serviceKey: string; tmdbKey: string } {
- if (!SUPABASE_URL) throw new Error("SUPABASE_URL not set");
- if (!SUPABASE_SERVICE_ROLE_KEY) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set");
- if (!TMDB_API_KEY) throw new Error("TMDB_API_KEY not set");
- return {
- supabaseUrl: SUPABASE_URL,
- serviceKey: SUPABASE_SERVICE_ROLE_KEY,
- tmdbKey: TMDB_API_KEY,
- };
- }
- async function fetchTMDBPopular(tmdbKey: string, page: number): Promise<TMDBMovie[]> {
- const params = new URLSearchParams({
- language: "en-US",
- page: String(page),
- include_adult: "false",
- });
- const res = await fetch(`${TMDB_API_BASE_URL}/movie/popular?${params.toString()}`, {
- headers: {
- Authorization: `Bearer ${tmdbKey}`,
- Accept: "application/json",
- },
- });
- if (!res.ok) {
- throw new Error(`TMDB popular request failed: ${res.status}`);
- }
- const data = (await res.json()) as TMDBPopularResponse;
- // Server-side adult filter (defense in depth alongside include_adult=false).
- return data.results.filter((m) => !m.adult && m.poster_path);
- }
- async function refreshReelPosters(): Promise<void> {
- const { supabaseUrl, serviceKey, tmdbKey } = requireEnv();
- const popular = await fetchTMDBPopular(tmdbKey, 1);
- const seen = new Set<number>();
- const posters: ReelPoster[] = [];
- for (const p of [
- ...PINNED_REEL_POSTERS,
- ...popular.map((m) => ({
- tmdb_id: m.id,
- poster_path: m.poster_path!,
- title: m.title,
- })),
- ]) {
- if (seen.has(p.tmdb_id)) continue;
- seen.add(p.tmdb_id);
- posters.push(p);
- if (posters.length >= REEL_POSTER_COUNT) break;
- }
- if (posters.length === 0) {
- throw new Error("No posters resolved; aborting refresh to avoid wiping table");
- }
- const supabase = createClient(supabaseUrl, serviceKey, {
- auth: { persistSession: false, autoRefreshToken: false },
- });
- // Replace full set: delete-all then insert. Bi-weekly cadence makes this safe.
- const { error: delErr } = await supabase.from("landing_reel_posters").delete().gte("id", 0);
- if (delErr) throw new Error(`Delete failed: ${delErr.message}`);
- const { error: insErr } = await supabase.from("landing_reel_posters").insert(
- posters.map((p) => ({
- tmdb_id: p.tmdb_id,
- poster_path: p.poster_path,
- title: p.title,
- })),
- );
- if (insErr) throw new Error(`Insert failed: ${insErr.message}`);
- console.log(`[${new Date().toISOString()}] Reel refresh: wrote ${posters.length} posters`);
- }
- console.log("MovieDice cron service started");
- // Refresh landing reel posters — bi-weekly on the 1st and 15th at 3:00 AM UTC.
- // (PROJECT_SCOPE.md task 5.2 specifies bi-weekly cadence.)
- cron.schedule("0 3 1,15 * *", async () => {
- console.log(`[${new Date().toISOString()}] Reel refresh: starting`);
- try {
- await refreshReelPosters();
- console.log(`[${new Date().toISOString()}] Reel refresh: complete`);
- } catch (err) {
- console.error(`[${new Date().toISOString()}] Reel refresh: failed`, err);
- }
- });
- // Refresh trailer URLs — daily at 4:00 AM UTC
- cron.schedule("0 4 * * *", async () => {
- console.log(`[${new Date().toISOString()}] Trailer refresh: starting`);
- // TODO: re-validate trailer URLs for movies missing trailers
- console.log(`[${new Date().toISOString()}] Trailer refresh: complete`);
- });
- // Refresh TMDB metadata — monthly on the 1st at 5:00 AM UTC
- cron.schedule("0 5 1 * *", async () => {
- console.log(`[${new Date().toISOString()}] Metadata refresh: starting`);
- // TODO: refresh metadata for movies where metadata_refreshed_at > 30 days
- console.log(`[${new Date().toISOString()}] Metadata refresh: complete`);
- });
- // Run reel refresh once at startup if the table appears empty, so a fresh
- // deploy doesn't have to wait up to two weeks for the first cron tick.
- (async () => {
- try {
- const { supabaseUrl, serviceKey } = requireEnv();
- const supabase = createClient(supabaseUrl, serviceKey, {
- auth: { persistSession: false, autoRefreshToken: false },
- });
- const { count, error } = await supabase
- .from("landing_reel_posters")
- .select("id", { count: "exact", head: true });
- if (error) {
- console.error("Startup reel-table check failed:", error.message);
- return;
- }
- if ((count ?? 0) === 0) {
- console.log(
- `[${new Date().toISOString()}] Reel refresh: table empty at startup, running initial refresh`,
- );
- await refreshReelPosters();
- }
- } catch (err) {
- console.error("Startup reel-refresh check failed:", err);
- }
- })();
- // Keep the process alive
- process.on("SIGTERM", () => {
- console.log("Received SIGTERM, shutting down cron service");
- process.exit(0);
- });
|