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; // Over-fetch discover so cert-rejections / dedupe don't starve the final set. const DISCOVER_FETCH_PAGES = 2; // 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", }, ]; // Pinned entries are an explicit editorial policy override: they bypass // the PG-13-or-better cert filter at every layer they touch. The route // layer already serves these unconditionally; this set is used in the // cron to skip per-movie cert checks for the same titles. const PINNED_TMDB_IDS = new Set(PINNED_REEL_POSTERS.map((p) => p.tmdb_id)); // Inlined from src/lib/tmdb/certification.ts — the cron container builds // standalone (cron/Dockerfile copies only index.ts), so we cannot import // from src/. Keep in sync with that file. const ALLOWED_CERTIFICATIONS: Record = { US: ["G", "PG", "PG-13"], GB: ["U", "PG", "12", "12A"], DE: ["0", "6", "12"], FR: ["U", "10", "12"], AU: ["G", "PG", "M"], CA: ["G", "PG", "14A"], NL: ["AL", "6", "9", "12"], ES: ["A", "7", "12"], IT: ["T", "6+", "12+"], JP: ["G", "PG12"], KR: ["ALL", "12"], BR: ["L", "10", "12"], MX: ["AA", "A", "B"], IE: ["G", "PG", "12A"], SE: ["Btl", "7", "11"], }; const DISCOVER_CERT_PARAMS: Record = { certification_country: "US", "certification.lte": "PG-13", include_adult: "false", }; interface TMDBReleaseDate { certification: string; iso_639_1: string; note: string; release_date: string; type: number; } interface TMDBReleaseDatesCountry { iso_3166_1: string; release_dates: TMDBReleaseDate[]; } interface TMDBReleaseDatesResponse { results: TMDBReleaseDatesCountry[]; } function isMovieAllowedByCert(releaseDates: TMDBReleaseDatesResponse | undefined | null): boolean { if (!releaseDates?.results?.length) return false; let sawRecognizedCert = false; let sawPositiveMatch = false; for (const country of releaseDates.results) { const allow = ALLOWED_CERTIFICATIONS[country.iso_3166_1]; if (!allow) continue; for (const rd of country.release_dates ?? []) { const cert = (rd.certification ?? "").trim(); if (!cert) continue; sawRecognizedCert = true; if (allow.includes(cert)) { sawPositiveMatch = true; } else { return false; } } } return sawRecognizedCert && sawPositiveMatch; } interface ReelPoster { tmdb_id: number; poster_path: string; title: string; } interface TMDBMovie { id: number; title: string; poster_path: string | null; adult: boolean; } interface TMDBDiscoverResponse { results: TMDBMovie[]; } interface TMDBMovieDetails { id: number; title: string; poster_path: string | null; adult: boolean; release_dates?: TMDBReleaseDatesResponse; } 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 tmdbGet( tmdbKey: string, path: string, params: Record, ): Promise { const qs = new URLSearchParams(params); const res = await fetch(`${TMDB_API_BASE_URL}${path}?${qs.toString()}`, { headers: { Authorization: `Bearer ${tmdbKey}`, Accept: "application/json", }, }); if (!res.ok) { throw new Error(`TMDB ${path} request failed: ${res.status}`); } return (await res.json()) as T; } async function fetchTMDBDiscover(tmdbKey: string, page: number): Promise { const data = await tmdbGet(tmdbKey, "/discover/movie", { language: "en-US", page: String(page), sort_by: "popularity.desc", ...DISCOVER_CERT_PARAMS, }); // Server-side adult filter (defense in depth alongside include_adult=false). return data.results.filter((m) => !m.adult && m.poster_path); } /** * Per-movie cert check via /movie/{id}?append_to_response=release_dates. * Returns true iff TMDB confirms the title is PG-13-or-better. * Individual fetch failures are treated as "not allowed" (log + skip), * so a transient TMDB blip never lets a movie slip past the policy. */ async function isAllowedByCertCheck(tmdbKey: string, tmdbId: number): Promise { // Pinned entries are an explicit editorial override — bypass the cert filter. if (PINNED_TMDB_IDS.has(tmdbId)) { console.log(`[cron] policy override: pinned tmdb_id=${tmdbId}`); return true; } try { const details = await tmdbGet(tmdbKey, `/movie/${tmdbId}`, { append_to_response: "release_dates", }); if (details.adult) return false; return isMovieAllowedByCert(details.release_dates); } catch (err) { console.warn( `[${new Date().toISOString()}] Cert check failed for tmdb_id=${tmdbId}, skipping:`, err instanceof Error ? err.message : err, ); return false; } } async function filterByCertConcurrent( tmdbKey: string, items: T[], ): Promise { const CONCURRENCY = 6; const results = new Array(items.length); let cursor = 0; async function worker(): Promise { while (true) { const i = cursor++; if (i >= items.length) return; results[i] = await isAllowedByCertCheck(tmdbKey, items[i].tmdb_id); } } await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, () => worker())); return items.filter((_, i) => results[i]); } async function refreshReelPosters(): Promise { const { supabaseUrl, serviceKey, tmdbKey } = requireEnv(); // 1. Pinned entries are an explicit editorial policy override and bypass // the cert filter (see PINNED_TMDB_IDS in isAllowedByCertCheck). They // are always inserted; each override is logged at filter time. const allowedPins = await filterByCertConcurrent(tmdbKey, PINNED_REEL_POSTERS); // 2. Fetch popular via discover with cert pre-filter, then belt-and-suspenders // per-movie cert check (mirrors fetchAndFilterByCert in src/lib/tmdb/client.ts). const discoverMovies: TMDBMovie[] = []; for (let page = 1; page <= DISCOVER_FETCH_PAGES; page++) { discoverMovies.push(...(await fetchTMDBDiscover(tmdbKey, page))); } const discoverPosters: ReelPoster[] = discoverMovies.map((m) => ({ tmdb_id: m.id, poster_path: m.poster_path!, title: m.title, })); const allowedDiscover = await filterByCertConcurrent(tmdbKey, discoverPosters); const seen = new Set(); const posters: ReelPoster[] = []; for (const p of [...allowedPins, ...allowedDiscover]) { 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. // When implemented: this stub does not touch TMDB. If it ever fetches new // movie metadata from TMDB, gate every result with isMovieAllowedByCert // (use append_to_response=release_dates as in refreshReelPosters). 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. // When implemented: re-check isMovieAllowedByCert on each refreshed title; // if a movie has fallen out of the allowlist, surface it to admin review // rather than silently keeping stale metadata. 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); });