index.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  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. // Mirror of PINNED_REEL_POSTERS in src/app/api/tmdb/reel-posters/route.ts.
  6. // Keep in sync if either side changes.
  7. const PINNED_REEL_POSTERS: ReelPoster[] = [
  8. {
  9. tmdb_id: 615,
  10. title: "The Passion of the Christ",
  11. poster_path: "/rBM5o2HpmCfDejuIPybI09tkY3V.jpg",
  12. },
  13. ];
  14. interface ReelPoster {
  15. tmdb_id: number;
  16. poster_path: string;
  17. title: string;
  18. }
  19. interface TMDBMovie {
  20. id: number;
  21. title: string;
  22. poster_path: string | null;
  23. adult: boolean;
  24. }
  25. interface TMDBPopularResponse {
  26. results: TMDBMovie[];
  27. }
  28. const SUPABASE_URL = process.env.SUPABASE_URL;
  29. const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
  30. const TMDB_API_KEY = process.env.TMDB_API_KEY;
  31. function requireEnv(): { supabaseUrl: string; serviceKey: string; tmdbKey: string } {
  32. if (!SUPABASE_URL) throw new Error("SUPABASE_URL not set");
  33. if (!SUPABASE_SERVICE_ROLE_KEY) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set");
  34. if (!TMDB_API_KEY) throw new Error("TMDB_API_KEY not set");
  35. return {
  36. supabaseUrl: SUPABASE_URL,
  37. serviceKey: SUPABASE_SERVICE_ROLE_KEY,
  38. tmdbKey: TMDB_API_KEY,
  39. };
  40. }
  41. async function fetchTMDBPopular(tmdbKey: string, page: number): Promise<TMDBMovie[]> {
  42. const params = new URLSearchParams({
  43. language: "en-US",
  44. page: String(page),
  45. include_adult: "false",
  46. });
  47. const res = await fetch(`${TMDB_API_BASE_URL}/movie/popular?${params.toString()}`, {
  48. headers: {
  49. Authorization: `Bearer ${tmdbKey}`,
  50. Accept: "application/json",
  51. },
  52. });
  53. if (!res.ok) {
  54. throw new Error(`TMDB popular request failed: ${res.status}`);
  55. }
  56. const data = (await res.json()) as TMDBPopularResponse;
  57. // Server-side adult filter (defense in depth alongside include_adult=false).
  58. return data.results.filter((m) => !m.adult && m.poster_path);
  59. }
  60. async function refreshReelPosters(): Promise<void> {
  61. const { supabaseUrl, serviceKey, tmdbKey } = requireEnv();
  62. const popular = await fetchTMDBPopular(tmdbKey, 1);
  63. const seen = new Set<number>();
  64. const posters: ReelPoster[] = [];
  65. for (const p of [
  66. ...PINNED_REEL_POSTERS,
  67. ...popular.map((m) => ({
  68. tmdb_id: m.id,
  69. poster_path: m.poster_path!,
  70. title: m.title,
  71. })),
  72. ]) {
  73. if (seen.has(p.tmdb_id)) continue;
  74. seen.add(p.tmdb_id);
  75. posters.push(p);
  76. if (posters.length >= REEL_POSTER_COUNT) break;
  77. }
  78. if (posters.length === 0) {
  79. throw new Error("No posters resolved; aborting refresh to avoid wiping table");
  80. }
  81. const supabase = createClient(supabaseUrl, serviceKey, {
  82. auth: { persistSession: false, autoRefreshToken: false },
  83. });
  84. // Replace full set: delete-all then insert. Bi-weekly cadence makes this safe.
  85. const { error: delErr } = await supabase.from("landing_reel_posters").delete().gte("id", 0);
  86. if (delErr) throw new Error(`Delete failed: ${delErr.message}`);
  87. const { error: insErr } = await supabase.from("landing_reel_posters").insert(
  88. posters.map((p) => ({
  89. tmdb_id: p.tmdb_id,
  90. poster_path: p.poster_path,
  91. title: p.title,
  92. })),
  93. );
  94. if (insErr) throw new Error(`Insert failed: ${insErr.message}`);
  95. console.log(`[${new Date().toISOString()}] Reel refresh: wrote ${posters.length} posters`);
  96. }
  97. console.log("MovieDice cron service started");
  98. // Refresh landing reel posters — bi-weekly on the 1st and 15th at 3:00 AM UTC.
  99. // (PROJECT_SCOPE.md task 5.2 specifies bi-weekly cadence.)
  100. cron.schedule("0 3 1,15 * *", async () => {
  101. console.log(`[${new Date().toISOString()}] Reel refresh: starting`);
  102. try {
  103. await refreshReelPosters();
  104. console.log(`[${new Date().toISOString()}] Reel refresh: complete`);
  105. } catch (err) {
  106. console.error(`[${new Date().toISOString()}] Reel refresh: failed`, err);
  107. }
  108. });
  109. // Refresh trailer URLs — daily at 4:00 AM UTC
  110. cron.schedule("0 4 * * *", async () => {
  111. console.log(`[${new Date().toISOString()}] Trailer refresh: starting`);
  112. // TODO: re-validate trailer URLs for movies missing trailers
  113. console.log(`[${new Date().toISOString()}] Trailer refresh: complete`);
  114. });
  115. // Refresh TMDB metadata — monthly on the 1st at 5:00 AM UTC
  116. cron.schedule("0 5 1 * *", async () => {
  117. console.log(`[${new Date().toISOString()}] Metadata refresh: starting`);
  118. // TODO: refresh metadata for movies where metadata_refreshed_at > 30 days
  119. console.log(`[${new Date().toISOString()}] Metadata refresh: complete`);
  120. });
  121. // Run reel refresh once at startup if the table appears empty, so a fresh
  122. // deploy doesn't have to wait up to two weeks for the first cron tick.
  123. (async () => {
  124. try {
  125. const { supabaseUrl, serviceKey } = requireEnv();
  126. const supabase = createClient(supabaseUrl, serviceKey, {
  127. auth: { persistSession: false, autoRefreshToken: false },
  128. });
  129. const { count, error } = await supabase
  130. .from("landing_reel_posters")
  131. .select("id", { count: "exact", head: true });
  132. if (error) {
  133. console.error("Startup reel-table check failed:", error.message);
  134. return;
  135. }
  136. if ((count ?? 0) === 0) {
  137. console.log(
  138. `[${new Date().toISOString()}] Reel refresh: table empty at startup, running initial refresh`,
  139. );
  140. await refreshReelPosters();
  141. }
  142. } catch (err) {
  143. console.error("Startup reel-refresh check failed:", err);
  144. }
  145. })();
  146. // Keep the process alive
  147. process.on("SIGTERM", () => {
  148. console.log("Received SIGTERM, shutting down cron service");
  149. process.exit(0);
  150. });