Просмотр исходного кода

[Cron] Bi-weekly reel-posters refresh from TMDB popular

Adds cron job (0 3 1,15 * *) that refreshes landing_reel_posters from
TMDB /movie/popular: filters adult, dedupes against PINNED_REEL_POSTERS,
caps at REEL_POSTER_COUNT, then delete-all + insert via service-role
client. Aborts on zero results (TMDB outage guard) so the table can't be
wiped. Startup self-heal runs once if the table is empty at boot.
/api/tmdb/reel-posters now reads from the table first; live-TMDB fallback
preserved when empty. Adds @supabase/supabase-js to cron/package.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User 1 месяц назад
Родитель
Сommit
b164f471cf
3 измененных файлов с 176 добавлено и 16 удалено
  1. 146 4
      cron/index.ts
  2. 1 0
      cron/package.json
  3. 29 12
      src/app/api/tmdb/reel-posters/route.ts

+ 146 - 4
cron/index.ts

@@ -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");

+ 1 - 0
cron/package.json

@@ -3,6 +3,7 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@supabase/supabase-js": "^2.45.0",
     "node-cron": "^3.0.3"
   },
   "devDependencies": {

+ 29 - 12
src/app/api/tmdb/reel-posters/route.ts

@@ -1,5 +1,6 @@
 import { NextResponse } from "next/server";
 import { REEL_POSTER_COUNT } from "@/lib/constants";
+import { getSupabaseAdminClient } from "@/lib/supabase/admin";
 import { fetchTMDBMovies } from "@/lib/tmdb-fetch";
 
 interface ReelPoster {
@@ -9,6 +10,7 @@ interface ReelPoster {
 }
 
 // Always-pinned entries that lead the carousel.
+// Keep in sync with cron/index.ts (the bi-weekly refresh job mirrors this list).
 const PINNED_REEL_POSTERS: ReelPoster[] = [
   {
     tmdb_id: 615,
@@ -17,10 +19,34 @@ const PINNED_REEL_POSTERS: ReelPoster[] = [
   },
 ];
 
-// Fallback: fetch popular movies for reel posters.
-// In production, the cron job populates the landing_reel_posters table.
+function dedupedWithPins(rows: ReelPoster[]): ReelPoster[] {
+  const seen = new Set<number>();
+  const posters: ReelPoster[] = [];
+  for (const p of [...PINNED_REEL_POSTERS, ...rows]) {
+    if (seen.has(p.tmdb_id)) continue;
+    seen.add(p.tmdb_id);
+    posters.push(p);
+    if (posters.length >= REEL_POSTER_COUNT) break;
+  }
+  return posters;
+}
+
+// Reads from landing_reel_posters (populated by cron bi-weekly).
+// Falls back to TMDB live if the table is empty (e.g. fresh dev DB).
 export async function GET() {
   try {
+    const supabase = getSupabaseAdminClient();
+    const { data: rows, error: dbErr } = await supabase
+      .from("landing_reel_posters")
+      .select("tmdb_id, poster_path, title")
+      .order("id", { ascending: true })
+      .limit(REEL_POSTER_COUNT);
+
+    if (!dbErr && rows && rows.length > 0) {
+      return NextResponse.json({ posters: dedupedWithPins(rows) });
+    }
+
+    // Fallback: TMDB live. Only hit on cold dev DBs or before the first cron run.
     const params = new URLSearchParams({
       language: "en-US",
       page: "1",
@@ -39,16 +65,7 @@ export async function GET() {
       title: m.title,
     }));
 
-    const seen = new Set<number>();
-    const posters: ReelPoster[] = [];
-    for (const p of [...PINNED_REEL_POSTERS, ...popular]) {
-      if (seen.has(p.tmdb_id)) continue;
-      seen.add(p.tmdb_id);
-      posters.push(p);
-      if (posters.length >= REEL_POSTER_COUNT) break;
-    }
-
-    return NextResponse.json({ posters });
+    return NextResponse.json({ posters: dedupedWithPins(popular) });
   } catch {
     return NextResponse.json({ error: "Internal server error" }, { status: 500 });
   }