| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- 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<number>(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<string, string[]> = {
- 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<string, string> = {
- 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<T>(
- tmdbKey: string,
- path: string,
- params: Record<string, string>,
- ): Promise<T> {
- 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<TMDBMovie[]> {
- const data = await tmdbGet<TMDBDiscoverResponse>(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<boolean> {
- // 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<TMDBMovieDetails>(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<T extends { tmdb_id: number }>(
- tmdbKey: string,
- items: T[],
- ): Promise<T[]> {
- const CONCURRENCY = 6;
- const results = new Array<boolean>(items.length);
- let cursor = 0;
- async function worker(): Promise<void> {
- 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<void> {
- 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<number>();
- 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);
- });
|