index.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import * as cron from "node-cron";
  2. import { createClient } from "@supabase/supabase-js";
  3. const TMDB_API_BASE_URL = "https://api.themoviedb.org/3";
  4. const REEL_POSTER_COUNT = 20;
  5. // Over-fetch discover so cert-rejections / dedupe don't starve the final set.
  6. const DISCOVER_FETCH_PAGES = 2;
  7. // Mirror of PINNED_REEL_POSTERS in src/app/api/tmdb/reel-posters/route.ts.
  8. // Keep in sync if either side changes.
  9. const PINNED_REEL_POSTERS: ReelPoster[] = [
  10. {
  11. tmdb_id: 615,
  12. title: "The Passion of the Christ",
  13. poster_path: "/rBM5o2HpmCfDejuIPybI09tkY3V.jpg",
  14. },
  15. ];
  16. // Pinned entries are an explicit editorial policy override: they bypass
  17. // the PG-13-or-better cert filter at every layer they touch. The route
  18. // layer already serves these unconditionally; this set is used in the
  19. // cron to skip per-movie cert checks for the same titles.
  20. const PINNED_TMDB_IDS = new Set<number>(PINNED_REEL_POSTERS.map((p) => p.tmdb_id));
  21. // Inlined from src/lib/tmdb/certification.ts — the cron container builds
  22. // standalone (cron/Dockerfile copies only index.ts), so we cannot import
  23. // from src/. Keep in sync with that file.
  24. const ALLOWED_CERTIFICATIONS: Record<string, string[]> = {
  25. US: ["G", "PG", "PG-13"],
  26. GB: ["U", "PG", "12", "12A"],
  27. DE: ["0", "6", "12"],
  28. FR: ["U", "10", "12"],
  29. AU: ["G", "PG", "M"],
  30. CA: ["G", "PG", "14A"],
  31. NL: ["AL", "6", "9", "12"],
  32. ES: ["A", "7", "12"],
  33. IT: ["T", "6+", "12+"],
  34. JP: ["G", "PG12"],
  35. KR: ["ALL", "12"],
  36. BR: ["L", "10", "12"],
  37. MX: ["AA", "A", "B"],
  38. IE: ["G", "PG", "12A"],
  39. SE: ["Btl", "7", "11"],
  40. };
  41. const DISCOVER_CERT_PARAMS: Record<string, string> = {
  42. certification_country: "US",
  43. "certification.lte": "PG-13",
  44. include_adult: "false",
  45. };
  46. interface TMDBReleaseDate {
  47. certification: string;
  48. iso_639_1: string;
  49. note: string;
  50. release_date: string;
  51. type: number;
  52. }
  53. interface TMDBReleaseDatesCountry {
  54. iso_3166_1: string;
  55. release_dates: TMDBReleaseDate[];
  56. }
  57. interface TMDBReleaseDatesResponse {
  58. results: TMDBReleaseDatesCountry[];
  59. }
  60. function isMovieAllowedByCert(releaseDates: TMDBReleaseDatesResponse | undefined | null): boolean {
  61. if (!releaseDates?.results?.length) return false;
  62. let sawRecognizedCert = false;
  63. let sawPositiveMatch = false;
  64. for (const country of releaseDates.results) {
  65. const allow = ALLOWED_CERTIFICATIONS[country.iso_3166_1];
  66. if (!allow) continue;
  67. for (const rd of country.release_dates ?? []) {
  68. const cert = (rd.certification ?? "").trim();
  69. if (!cert) continue;
  70. sawRecognizedCert = true;
  71. if (allow.includes(cert)) {
  72. sawPositiveMatch = true;
  73. } else {
  74. return false;
  75. }
  76. }
  77. }
  78. return sawRecognizedCert && sawPositiveMatch;
  79. }
  80. interface ReelPoster {
  81. tmdb_id: number;
  82. poster_path: string;
  83. title: string;
  84. }
  85. interface TMDBMovie {
  86. id: number;
  87. title: string;
  88. poster_path: string | null;
  89. adult: boolean;
  90. }
  91. interface TMDBDiscoverResponse {
  92. results: TMDBMovie[];
  93. }
  94. interface TMDBMovieDetails {
  95. id: number;
  96. title: string;
  97. poster_path: string | null;
  98. adult: boolean;
  99. release_dates?: TMDBReleaseDatesResponse;
  100. }
  101. const SUPABASE_URL = process.env.SUPABASE_URL;
  102. const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
  103. const TMDB_API_KEY = process.env.TMDB_API_KEY;
  104. function requireEnv(): { supabaseUrl: string; serviceKey: string; tmdbKey: string } {
  105. if (!SUPABASE_URL) throw new Error("SUPABASE_URL not set");
  106. if (!SUPABASE_SERVICE_ROLE_KEY) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set");
  107. if (!TMDB_API_KEY) throw new Error("TMDB_API_KEY not set");
  108. return {
  109. supabaseUrl: SUPABASE_URL,
  110. serviceKey: SUPABASE_SERVICE_ROLE_KEY,
  111. tmdbKey: TMDB_API_KEY,
  112. };
  113. }
  114. async function tmdbGet<T>(
  115. tmdbKey: string,
  116. path: string,
  117. params: Record<string, string>,
  118. ): Promise<T> {
  119. const qs = new URLSearchParams(params);
  120. const res = await fetch(`${TMDB_API_BASE_URL}${path}?${qs.toString()}`, {
  121. headers: {
  122. Authorization: `Bearer ${tmdbKey}`,
  123. Accept: "application/json",
  124. },
  125. });
  126. if (!res.ok) {
  127. throw new Error(`TMDB ${path} request failed: ${res.status}`);
  128. }
  129. return (await res.json()) as T;
  130. }
  131. async function fetchTMDBDiscover(tmdbKey: string, page: number): Promise<TMDBMovie[]> {
  132. const data = await tmdbGet<TMDBDiscoverResponse>(tmdbKey, "/discover/movie", {
  133. language: "en-US",
  134. page: String(page),
  135. sort_by: "popularity.desc",
  136. ...DISCOVER_CERT_PARAMS,
  137. });
  138. // Server-side adult filter (defense in depth alongside include_adult=false).
  139. return data.results.filter((m) => !m.adult && m.poster_path);
  140. }
  141. /**
  142. * Per-movie cert check via /movie/{id}?append_to_response=release_dates.
  143. * Returns true iff TMDB confirms the title is PG-13-or-better.
  144. * Individual fetch failures are treated as "not allowed" (log + skip),
  145. * so a transient TMDB blip never lets a movie slip past the policy.
  146. */
  147. async function isAllowedByCertCheck(tmdbKey: string, tmdbId: number): Promise<boolean> {
  148. // Pinned entries are an explicit editorial override — bypass the cert filter.
  149. if (PINNED_TMDB_IDS.has(tmdbId)) {
  150. console.log(`[cron] policy override: pinned tmdb_id=${tmdbId}`);
  151. return true;
  152. }
  153. try {
  154. const details = await tmdbGet<TMDBMovieDetails>(tmdbKey, `/movie/${tmdbId}`, {
  155. append_to_response: "release_dates",
  156. });
  157. if (details.adult) return false;
  158. return isMovieAllowedByCert(details.release_dates);
  159. } catch (err) {
  160. console.warn(
  161. `[${new Date().toISOString()}] Cert check failed for tmdb_id=${tmdbId}, skipping:`,
  162. err instanceof Error ? err.message : err,
  163. );
  164. return false;
  165. }
  166. }
  167. async function filterByCertConcurrent<T extends { tmdb_id: number }>(
  168. tmdbKey: string,
  169. items: T[],
  170. ): Promise<T[]> {
  171. const CONCURRENCY = 6;
  172. const results = new Array<boolean>(items.length);
  173. let cursor = 0;
  174. async function worker(): Promise<void> {
  175. while (true) {
  176. const i = cursor++;
  177. if (i >= items.length) return;
  178. results[i] = await isAllowedByCertCheck(tmdbKey, items[i].tmdb_id);
  179. }
  180. }
  181. await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, () => worker()));
  182. return items.filter((_, i) => results[i]);
  183. }
  184. async function refreshReelPosters(): Promise<void> {
  185. const { supabaseUrl, serviceKey, tmdbKey } = requireEnv();
  186. // 1. Pinned entries are an explicit editorial policy override and bypass
  187. // the cert filter (see PINNED_TMDB_IDS in isAllowedByCertCheck). They
  188. // are always inserted; each override is logged at filter time.
  189. const allowedPins = await filterByCertConcurrent(tmdbKey, PINNED_REEL_POSTERS);
  190. // 2. Fetch popular via discover with cert pre-filter, then belt-and-suspenders
  191. // per-movie cert check (mirrors fetchAndFilterByCert in src/lib/tmdb/client.ts).
  192. const discoverMovies: TMDBMovie[] = [];
  193. for (let page = 1; page <= DISCOVER_FETCH_PAGES; page++) {
  194. discoverMovies.push(...(await fetchTMDBDiscover(tmdbKey, page)));
  195. }
  196. const discoverPosters: ReelPoster[] = discoverMovies.map((m) => ({
  197. tmdb_id: m.id,
  198. poster_path: m.poster_path!,
  199. title: m.title,
  200. }));
  201. const allowedDiscover = await filterByCertConcurrent(tmdbKey, discoverPosters);
  202. const seen = new Set<number>();
  203. const posters: ReelPoster[] = [];
  204. for (const p of [...allowedPins, ...allowedDiscover]) {
  205. if (seen.has(p.tmdb_id)) continue;
  206. seen.add(p.tmdb_id);
  207. posters.push(p);
  208. if (posters.length >= REEL_POSTER_COUNT) break;
  209. }
  210. if (posters.length === 0) {
  211. throw new Error("No posters resolved; aborting refresh to avoid wiping table");
  212. }
  213. const supabase = createClient(supabaseUrl, serviceKey, {
  214. auth: { persistSession: false, autoRefreshToken: false },
  215. });
  216. // Replace full set: delete-all then insert. Bi-weekly cadence makes this safe.
  217. const { error: delErr } = await supabase.from("landing_reel_posters").delete().gte("id", 0);
  218. if (delErr) throw new Error(`Delete failed: ${delErr.message}`);
  219. const { error: insErr } = await supabase.from("landing_reel_posters").insert(
  220. posters.map((p) => ({
  221. tmdb_id: p.tmdb_id,
  222. poster_path: p.poster_path,
  223. title: p.title,
  224. })),
  225. );
  226. if (insErr) throw new Error(`Insert failed: ${insErr.message}`);
  227. console.log(`[${new Date().toISOString()}] Reel refresh: wrote ${posters.length} posters`);
  228. }
  229. console.log("MovieDice cron service started");
  230. // Refresh landing reel posters — bi-weekly on the 1st and 15th at 3:00 AM UTC.
  231. // (PROJECT_SCOPE.md task 5.2 specifies bi-weekly cadence.)
  232. cron.schedule("0 3 1,15 * *", async () => {
  233. console.log(`[${new Date().toISOString()}] Reel refresh: starting`);
  234. try {
  235. await refreshReelPosters();
  236. console.log(`[${new Date().toISOString()}] Reel refresh: complete`);
  237. } catch (err) {
  238. console.error(`[${new Date().toISOString()}] Reel refresh: failed`, err);
  239. }
  240. });
  241. // Refresh trailer URLs — daily at 4:00 AM UTC
  242. cron.schedule("0 4 * * *", async () => {
  243. console.log(`[${new Date().toISOString()}] Trailer refresh: starting`);
  244. // TODO: re-validate trailer URLs for movies missing trailers.
  245. // When implemented: this stub does not touch TMDB. If it ever fetches new
  246. // movie metadata from TMDB, gate every result with isMovieAllowedByCert
  247. // (use append_to_response=release_dates as in refreshReelPosters).
  248. console.log(`[${new Date().toISOString()}] Trailer refresh: complete`);
  249. });
  250. // Refresh TMDB metadata — monthly on the 1st at 5:00 AM UTC
  251. cron.schedule("0 5 1 * *", async () => {
  252. console.log(`[${new Date().toISOString()}] Metadata refresh: starting`);
  253. // TODO: refresh metadata for movies where metadata_refreshed_at > 30 days.
  254. // When implemented: re-check isMovieAllowedByCert on each refreshed title;
  255. // if a movie has fallen out of the allowlist, surface it to admin review
  256. // rather than silently keeping stale metadata.
  257. console.log(`[${new Date().toISOString()}] Metadata refresh: complete`);
  258. });
  259. // Run reel refresh once at startup if the table appears empty, so a fresh
  260. // deploy doesn't have to wait up to two weeks for the first cron tick.
  261. (async () => {
  262. try {
  263. const { supabaseUrl, serviceKey } = requireEnv();
  264. const supabase = createClient(supabaseUrl, serviceKey, {
  265. auth: { persistSession: false, autoRefreshToken: false },
  266. });
  267. const { count, error } = await supabase
  268. .from("landing_reel_posters")
  269. .select("id", { count: "exact", head: true });
  270. if (error) {
  271. console.error("Startup reel-table check failed:", error.message);
  272. return;
  273. }
  274. if ((count ?? 0) === 0) {
  275. console.log(
  276. `[${new Date().toISOString()}] Reel refresh: table empty at startup, running initial refresh`,
  277. );
  278. await refreshReelPosters();
  279. }
  280. } catch (err) {
  281. console.error("Startup reel-refresh check failed:", err);
  282. }
  283. })();
  284. // Keep the process alive
  285. process.on("SIGTERM", () => {
  286. console.log("Received SIGTERM, shutting down cron service");
  287. process.exit(0);
  288. });