|
|
@@ -1,12 +1,128 @@
|
|
|
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<TMDBMovie[]> {
|
|
|
+ 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<void> {
|
|
|
+ const { supabaseUrl, serviceKey, tmdbKey } = requireEnv();
|
|
|
+
|
|
|
+ const popular = await fetchTMDBPopular(tmdbKey, 1);
|
|
|
+
|
|
|
+ const seen = new Set<number>();
|
|
|
+ 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 — daily at 3:00 AM UTC
|
|
|
-cron.schedule("0 3 * * *", async () => {
|
|
|
+// 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`);
|
|
|
- // TODO: fetch trending movies from TMDB and update landing_reel_posters
|
|
|
- console.log(`[${new Date().toISOString()}] Reel refresh: complete`);
|
|
|
+ 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
|
|
|
@@ -23,6 +139,32 @@ cron.schedule("0 5 1 * *", async () => {
|
|
|
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");
|