import { eq } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { episodes, seasons, titleRecommendations, titles, } from "@/lib/db/schema"; import { getMovieDetails, getRecommendations, getSimilar, getTvDetails, getTvSeasonDetails, } from "@/lib/tmdb/client"; import { refreshAvailability } from "./availability"; export async function importTitle(tmdbId: number, type: "movie" | "tv") { const existing = await db .select() .from(titles) .where(eq(titles.tmdbId, tmdbId)) .get(); if (existing) { // For TV shows, check if seasons/episodes were actually loaded. // They may be missing if a prior fetch failed or the title was created // as a shell by the recommendations system (lastFetchedAt: null). if (existing.type === "tv") { const seasonCount = ( await db .select() .from(seasons) .where(eq(seasons.titleId, existing.id)) .all() ).length; if (seasonCount === 0) { const show = await getTvDetails(tmdbId); if (!existing.lastFetchedAt) { await db .update(titles) .set({ overview: show.overview, posterPath: show.poster_path, backdropPath: show.backdrop_path, status: show.status, lastFetchedAt: new Date(), }) .where(eq(titles.id, existing.id)) .run(); } await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons); refreshAvailability(existing.id).catch(() => {}); refreshRecommendations(existing.id).catch(() => {}); return db.select().from(titles).where(eq(titles.id, existing.id)).get(); } } return existing; } const now = new Date(); if (type === "movie") { const movie = await getMovieDetails(tmdbId); const row = await db .insert(titles) .values({ tmdbId: movie.id, type: "movie", title: movie.title, originalTitle: movie.original_title, overview: movie.overview, releaseDate: movie.release_date || null, posterPath: movie.poster_path, backdropPath: movie.backdrop_path, popularity: movie.popularity, voteAverage: movie.vote_average, voteCount: movie.vote_count, status: movie.status, lastFetchedAt: now, }) .returning() .get(); // Fire-and-forget: fetch availability & recommendations refreshAvailability(row.id).catch(() => {}); refreshRecommendations(row.id).catch(() => {}); return row; } const show = await getTvDetails(tmdbId); const row = await db .insert(titles) .values({ tmdbId: show.id, type: "tv", title: show.name, originalTitle: show.original_name, overview: show.overview, firstAirDate: show.first_air_date || null, posterPath: show.poster_path, backdropPath: show.backdrop_path, popularity: show.popularity, voteAverage: show.vote_average, voteCount: show.vote_count, status: show.status, lastFetchedAt: now, }) .returning() .get(); await refreshTvChildren(row.id, tmdbId, show.number_of_seasons); // Fire-and-forget: fetch availability & recommendations refreshAvailability(row.id).catch(() => {}); refreshRecommendations(row.id).catch(() => {}); return row; } export async function refreshTitle(titleId: string) { const title = await db .select() .from(titles) .where(eq(titles.id, titleId)) .get(); if (!title) return null; const now = new Date(); if (title.type === "movie") { const movie = await getMovieDetails(title.tmdbId); await db .update(titles) .set({ title: movie.title, originalTitle: movie.original_title, overview: movie.overview, releaseDate: movie.release_date || null, posterPath: movie.poster_path, backdropPath: movie.backdrop_path, popularity: movie.popularity, voteAverage: movie.vote_average, voteCount: movie.vote_count, status: movie.status, lastFetchedAt: now, }) .where(eq(titles.id, titleId)) .run(); } else { const show = await getTvDetails(title.tmdbId); await db .update(titles) .set({ title: show.name, originalTitle: show.original_name, overview: show.overview, firstAirDate: show.first_air_date || null, posterPath: show.poster_path, backdropPath: show.backdrop_path, popularity: show.popularity, voteAverage: show.vote_average, voteCount: show.vote_count, status: show.status, lastFetchedAt: now, }) .where(eq(titles.id, titleId)) .run(); await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons); } return db.select().from(titles).where(eq(titles.id, titleId)).get(); } export async function refreshTvChildren( titleId: string, tmdbId: number, numberOfSeasons: number, ) { const now = new Date(); for (let sn = 1; sn <= numberOfSeasons; sn++) { // Rate-limit: 250ms between TMDB calls if (sn > 1) await delay(250); try { const seasonData = await getTvSeasonDetails(tmdbId, sn); const seasonRow = await db .insert(seasons) .values({ titleId, seasonNumber: seasonData.season_number, name: seasonData.name, overview: seasonData.overview, posterPath: seasonData.poster_path, airDate: seasonData.air_date, lastFetchedAt: now, }) .onConflictDoUpdate({ target: [seasons.titleId, seasons.seasonNumber], set: { name: seasonData.name, overview: seasonData.overview, posterPath: seasonData.poster_path, airDate: seasonData.air_date, lastFetchedAt: now, }, }) .returning() .get(); for (const ep of seasonData.episodes) { await db .insert(episodes) .values({ seasonId: seasonRow.id, episodeNumber: ep.episode_number, name: ep.name, overview: ep.overview, stillPath: ep.still_path, airDate: ep.air_date, runtimeMinutes: ep.runtime, }) .onConflictDoUpdate({ target: [episodes.seasonId, episodes.episodeNumber], set: { name: ep.name, overview: ep.overview, stillPath: ep.still_path, airDate: ep.air_date, runtimeMinutes: ep.runtime, }, }) .run(); } } catch { // Skip this season and continue with the rest — partial data is // better than aborting entirely. The next refresh cycle will retry. console.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}`); } } } export async function refreshRecommendations(titleId: string) { const title = await db .select() .from(titles) .where(eq(titles.id, titleId)) .get(); if (!title) return; const now = new Date(); // Fetch both recommendations and similar const [recs, similar] = await Promise.all([ getRecommendations(title.tmdbId, title.type), getSimilar(title.tmdbId, title.type), ]); // Process recommendations for (let i = 0; i < recs.results.length && i < 20; i++) { const r = recs.results[i]; const type = r.media_type ?? title.type; if (type !== "movie" && type !== "tv") continue; // Minimal upsert of the recommended title const existing = await db .select() .from(titles) .where(eq(titles.tmdbId, r.id)) .get(); let recTitleId: string; if (existing) { recTitleId = existing.id; } else { const row = await db .insert(titles) .values({ tmdbId: r.id, type, title: r.title ?? r.name ?? "Unknown", originalTitle: r.original_title ?? r.original_name, overview: r.overview, releaseDate: r.release_date, firstAirDate: r.first_air_date, posterPath: r.poster_path, backdropPath: r.backdrop_path, popularity: r.popularity, voteAverage: r.vote_average, voteCount: r.vote_count, lastFetchedAt: null, }) .onConflictDoNothing() .returning() .get(); if (!row) { const found = await db .select() .from(titles) .where(eq(titles.tmdbId, r.id)) .get(); if (!found) continue; recTitleId = found.id; } else { recTitleId = row.id; } } await db .insert(titleRecommendations) .values({ titleId, recommendedTitleId: recTitleId, source: "tmdb_recommendations", rank: i + 1, lastFetchedAt: now, }) .onConflictDoUpdate({ target: [ titleRecommendations.titleId, titleRecommendations.recommendedTitleId, titleRecommendations.source, ], set: { rank: i + 1, lastFetchedAt: now }, }) .run(); } // Process similar for (let i = 0; i < similar.results.length && i < 20; i++) { const r = similar.results[i]; const type = r.media_type ?? title.type; if (type !== "movie" && type !== "tv") continue; const existing = await db .select() .from(titles) .where(eq(titles.tmdbId, r.id)) .get(); let recTitleId: string; if (existing) { recTitleId = existing.id; } else { const row = await db .insert(titles) .values({ tmdbId: r.id, type, title: r.title ?? r.name ?? "Unknown", originalTitle: r.original_title ?? r.original_name, overview: r.overview, releaseDate: r.release_date, firstAirDate: r.first_air_date, posterPath: r.poster_path, backdropPath: r.backdrop_path, popularity: r.popularity, voteAverage: r.vote_average, voteCount: r.vote_count, lastFetchedAt: null, }) .onConflictDoNothing() .returning() .get(); if (!row) { const found = await db .select() .from(titles) .where(eq(titles.tmdbId, r.id)) .get(); if (!found) continue; recTitleId = found.id; } else { recTitleId = row.id; } } await db .insert(titleRecommendations) .values({ titleId, recommendedTitleId: recTitleId, source: "tmdb_similar", rank: i + 1, lastFetchedAt: now, }) .onConflictDoUpdate({ target: [ titleRecommendations.titleId, titleRecommendations.recommendedTitleId, titleRecommendations.source, ], set: { rank: i + 1, lastFetchedAt: now }, }) .run(); } } function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); }