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 { 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 { const { supabaseUrl, serviceKey, tmdbKey } = requireEnv(); const popular = await fetchTMDBPopular(tmdbKey, 1); const seen = new Set(); 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); });