mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -04:00
- Add composite indexes on userEpisodeWatches and userMovieWatches for hot queries - Batch episode tracking: wrap season/batch watches in single transaction (~8 queries vs 8*N) - Fix N+1 patterns in credits, recommendations, and filmography with batch prefetch+insert - Stream TV season hydration via Suspense instead of blocking page render - Optimize webhook logs (per-connection LIMIT 10) and system health queries - Merge genre filter waterfalls into single Promise.all fetch - Migrate deprecated Jotai loadable() to unwrap() - Replace isolated createStore()+Provider with useHydrateAtoms on root store - Remove unnecessary atomWithStorage SSR guards (handled by Jotai internally) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
792 lines
24 KiB
TypeScript
792 lines
24 KiB
TypeScript
import { eq, inArray } from "drizzle-orm";
|
|
import { db } from "@/lib/db/client";
|
|
import {
|
|
availabilityOffers,
|
|
episodes,
|
|
seasons,
|
|
titleRecommendations,
|
|
titles,
|
|
} from "@/lib/db/schema";
|
|
import { createLogger } from "@/lib/logger";
|
|
import {
|
|
getMovieDetails,
|
|
getRecommendations,
|
|
getSimilar,
|
|
getTvDetails,
|
|
getTvSeasonDetails,
|
|
getVideos,
|
|
} from "@/lib/tmdb/client";
|
|
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
|
import type { TmdbVideo } from "@/lib/tmdb/types";
|
|
import type {
|
|
AvailabilityOffer,
|
|
CastMember,
|
|
Episode,
|
|
ResolvedTitle,
|
|
Season,
|
|
} from "@/lib/types/title";
|
|
import { refreshAvailability } from "./availability";
|
|
import { extractAndStoreColors, parseColorPalette } from "./colors";
|
|
import { getCastForTitle, refreshCredits } from "./credits";
|
|
import {
|
|
cacheEpisodeStills,
|
|
cacheImagesForTitle,
|
|
imageCacheEnabled,
|
|
} from "./image-cache";
|
|
|
|
const log = createLogger("metadata");
|
|
|
|
/**
|
|
* Insert a title row, or return the existing one if a concurrent insert won the race.
|
|
* Catches SQLITE_CONSTRAINT_UNIQUE and falls back to a SELECT.
|
|
*/
|
|
function insertTitleOrGet(values: typeof titles.$inferInsert, tmdbId: number) {
|
|
try {
|
|
return db.insert(titles).values(values).returning().get();
|
|
} catch (err: unknown) {
|
|
if (
|
|
err instanceof Error &&
|
|
"code" in err &&
|
|
err.code === "SQLITE_CONSTRAINT_UNIQUE"
|
|
) {
|
|
log.debug(`TMDB ${tmdbId} was inserted concurrently, returning existing`);
|
|
return db.select().from(titles).where(eq(titles.tmdbId, tmdbId)).get();
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
type ImportResult = ReturnType<typeof _importTitle>;
|
|
|
|
/** In-flight import promises keyed by tmdbId — coalesces concurrent calls */
|
|
const inflightImports = new Map<number, ImportResult>();
|
|
|
|
export function importTitle(
|
|
tmdbId: number,
|
|
type: "movie" | "tv",
|
|
options?: { awaitEnrichment?: boolean },
|
|
): ImportResult {
|
|
const inflight = inflightImports.get(tmdbId);
|
|
if (inflight) {
|
|
log.debug(`Import already in-flight for TMDB ${tmdbId}, coalescing`);
|
|
return inflight;
|
|
}
|
|
|
|
const promise = _importTitle(tmdbId, type, options).finally(() => {
|
|
inflightImports.delete(tmdbId);
|
|
}) as ImportResult;
|
|
inflightImports.set(tmdbId, promise);
|
|
return promise;
|
|
}
|
|
|
|
async function _importTitle(
|
|
tmdbId: number,
|
|
type: "movie" | "tv",
|
|
options?: { awaitEnrichment?: boolean },
|
|
) {
|
|
log.debug(`Importing ${type} TMDB ${tmdbId}`);
|
|
|
|
const awaitEnrichment = options?.awaitEnrichment ?? false;
|
|
const existing = 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 = db
|
|
.select()
|
|
.from(seasons)
|
|
.where(eq(seasons.titleId, existing.id))
|
|
.all().length;
|
|
if (seasonCount === 0) {
|
|
const show = await getTvDetails(tmdbId);
|
|
if (!existing.lastFetchedAt) {
|
|
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);
|
|
if (awaitEnrichment) {
|
|
await Promise.all([
|
|
refreshAvailability(existing.id).catch((err) =>
|
|
log.debug("Availability enrichment failed:", err),
|
|
),
|
|
refreshRecommendations(existing.id).catch((err) =>
|
|
log.debug("Recommendations enrichment failed:", err),
|
|
),
|
|
extractAndStoreColors(existing.id, show.poster_path).catch((err) =>
|
|
log.debug("Color extraction failed:", err),
|
|
),
|
|
refreshCredits(existing.id).catch((err) =>
|
|
log.debug("Credits enrichment failed:", err),
|
|
),
|
|
]);
|
|
} else {
|
|
refreshAvailability(existing.id).catch((err) =>
|
|
log.debug("Availability enrichment failed:", err),
|
|
);
|
|
refreshRecommendations(existing.id).catch((err) =>
|
|
log.debug("Recommendations enrichment failed:", err),
|
|
);
|
|
refreshCredits(existing.id).catch((err) =>
|
|
log.debug("Credits enrichment failed:", err),
|
|
);
|
|
}
|
|
if (imageCacheEnabled()) {
|
|
cacheImagesForTitle(existing.id).catch((err) =>
|
|
log.debug("Image caching failed:", err),
|
|
);
|
|
cacheEpisodeStills(existing.id).catch((err) =>
|
|
log.debug("Episode stills caching failed:", err),
|
|
);
|
|
}
|
|
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 = insertTitleOrGet(
|
|
{
|
|
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,
|
|
},
|
|
tmdbId,
|
|
);
|
|
if (!row) return undefined;
|
|
if (awaitEnrichment) {
|
|
await Promise.all([
|
|
refreshAvailability(row.id).catch((err) =>
|
|
log.debug("Availability enrichment failed:", err),
|
|
),
|
|
refreshRecommendations(row.id).catch((err) =>
|
|
log.debug("Recommendations enrichment failed:", err),
|
|
),
|
|
extractAndStoreColors(row.id, movie.poster_path).catch((err) =>
|
|
log.debug("Color extraction failed:", err),
|
|
),
|
|
refreshCredits(row.id).catch((err) =>
|
|
log.debug("Credits enrichment failed:", err),
|
|
),
|
|
]);
|
|
} else {
|
|
refreshAvailability(row.id).catch((err) =>
|
|
log.debug("Availability enrichment failed:", err),
|
|
);
|
|
refreshRecommendations(row.id).catch((err) =>
|
|
log.debug("Recommendations enrichment failed:", err),
|
|
);
|
|
extractAndStoreColors(row.id, movie.poster_path).catch((err) =>
|
|
log.debug("Color extraction failed:", err),
|
|
);
|
|
refreshCredits(row.id).catch((err) =>
|
|
log.debug("Credits enrichment failed:", err),
|
|
);
|
|
}
|
|
refreshTrailer(row.id).catch((err) =>
|
|
log.debug("Trailer enrichment failed:", err),
|
|
);
|
|
if (imageCacheEnabled()) {
|
|
cacheImagesForTitle(row.id).catch((err) =>
|
|
log.debug("Image caching failed:", err),
|
|
);
|
|
}
|
|
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
|
|
return row;
|
|
}
|
|
|
|
const show = await getTvDetails(tmdbId);
|
|
const row = insertTitleOrGet(
|
|
{
|
|
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,
|
|
},
|
|
tmdbId,
|
|
);
|
|
if (!row) return undefined;
|
|
|
|
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
|
|
if (awaitEnrichment) {
|
|
await Promise.all([
|
|
refreshAvailability(row.id).catch((err) =>
|
|
log.debug("Availability enrichment failed:", err),
|
|
),
|
|
refreshRecommendations(row.id).catch((err) =>
|
|
log.debug("Recommendations enrichment failed:", err),
|
|
),
|
|
extractAndStoreColors(row.id, show.poster_path).catch((err) =>
|
|
log.debug("Color extraction failed:", err),
|
|
),
|
|
refreshCredits(row.id).catch((err) =>
|
|
log.debug("Credits enrichment failed:", err),
|
|
),
|
|
]);
|
|
} else {
|
|
refreshAvailability(row.id).catch((err) =>
|
|
log.debug("Availability enrichment failed:", err),
|
|
);
|
|
refreshRecommendations(row.id).catch((err) =>
|
|
log.debug("Recommendations enrichment failed:", err),
|
|
);
|
|
extractAndStoreColors(row.id, show.poster_path).catch((err) =>
|
|
log.debug("Color extraction failed:", err),
|
|
);
|
|
refreshCredits(row.id).catch((err) =>
|
|
log.debug("Credits enrichment failed:", err),
|
|
);
|
|
}
|
|
refreshTrailer(row.id).catch((err) =>
|
|
log.debug("Trailer enrichment failed:", err),
|
|
);
|
|
if (imageCacheEnabled()) {
|
|
cacheImagesForTitle(row.id).catch((err) =>
|
|
log.debug("Image caching failed:", err),
|
|
);
|
|
cacheEpisodeStills(row.id).catch((err) =>
|
|
log.debug("Episode stills caching failed:", err),
|
|
);
|
|
}
|
|
log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`);
|
|
return row;
|
|
}
|
|
|
|
export async function refreshTitle(titleId: string) {
|
|
const title = 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);
|
|
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);
|
|
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);
|
|
}
|
|
|
|
const updated = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
|
if (updated) {
|
|
extractAndStoreColors(updated.id, updated.posterPath).catch((err) =>
|
|
log.debug("Color extraction failed:", err),
|
|
);
|
|
refreshTrailer(updated.id).catch((err) =>
|
|
log.debug("Trailer enrichment failed:", err),
|
|
);
|
|
refreshCredits(updated.id).catch((err) =>
|
|
log.debug("Credits enrichment failed:", err),
|
|
);
|
|
if (imageCacheEnabled()) {
|
|
cacheImagesForTitle(updated.id).catch((err) =>
|
|
log.debug("Image caching failed:", err),
|
|
);
|
|
if (updated.type === "tv") {
|
|
cacheEpisodeStills(updated.id).catch((err) =>
|
|
log.debug("Episode stills caching failed:", err),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
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 = 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) {
|
|
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 (err) {
|
|
// Skip this season and continue with the rest — partial data is
|
|
// better than aborting entirely. The next refresh cycle will retry.
|
|
log.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function refreshRecommendations(titleId: string) {
|
|
const title = 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),
|
|
]);
|
|
|
|
log.debug(
|
|
`Fetched ${recs.results.length} recommendations and ${similar.results.length} similar for title ${titleId}`,
|
|
);
|
|
|
|
// Collect all valid results with their source/rank
|
|
interface RecItem {
|
|
result: (typeof recs.results)[0];
|
|
type: "movie" | "tv";
|
|
source: "tmdb_recommendations" | "tmdb_similar";
|
|
rank: number;
|
|
}
|
|
const allItems: RecItem[] = [];
|
|
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") {
|
|
allItems.push({
|
|
result: r,
|
|
type,
|
|
source: "tmdb_recommendations",
|
|
rank: i + 1,
|
|
});
|
|
}
|
|
}
|
|
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") {
|
|
allItems.push({ result: r, type, source: "tmdb_similar", rank: i + 1 });
|
|
}
|
|
}
|
|
|
|
if (allItems.length === 0) return;
|
|
|
|
// Batch prefetch existing titles (1 query)
|
|
const tmdbIds = [...new Set(allItems.map((item) => item.result.id))];
|
|
const existingTitles = db
|
|
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
|
.from(titles)
|
|
.where(inArray(titles.tmdbId, tmdbIds))
|
|
.all();
|
|
const titleIdMap = new Map<number, string>(
|
|
existingTitles.map((t) => [t.tmdbId, t.id]),
|
|
);
|
|
|
|
// Insert missing titles + upsert recommendations in a single transaction
|
|
db.transaction((tx) => {
|
|
// Insert only new titles
|
|
const newItems = allItems.filter((item) => !titleIdMap.has(item.result.id));
|
|
const insertedTmdbIds = new Set<number>();
|
|
for (const item of newItems) {
|
|
if (insertedTmdbIds.has(item.result.id)) continue;
|
|
insertedTmdbIds.add(item.result.id);
|
|
const r = item.result;
|
|
const row = tx
|
|
.insert(titles)
|
|
.values({
|
|
tmdbId: r.id,
|
|
type: item.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) titleIdMap.set(r.id, row.id);
|
|
}
|
|
|
|
// One fallback query for any that conflicted
|
|
const stillMissing = [...insertedTmdbIds].filter(
|
|
(id) => !titleIdMap.has(id),
|
|
);
|
|
if (stillMissing.length > 0) {
|
|
const fallbacks = tx
|
|
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
|
.from(titles)
|
|
.where(inArray(titles.tmdbId, stillMissing))
|
|
.all();
|
|
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
|
|
}
|
|
|
|
// Upsert all recommendation rows
|
|
for (const item of allItems) {
|
|
const recTitleId = titleIdMap.get(item.result.id);
|
|
if (!recTitleId) continue;
|
|
tx.insert(titleRecommendations)
|
|
.values({
|
|
titleId,
|
|
recommendedTitleId: recTitleId,
|
|
source: item.source,
|
|
rank: item.rank,
|
|
lastFetchedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
titleRecommendations.titleId,
|
|
titleRecommendations.recommendedTitleId,
|
|
titleRecommendations.source,
|
|
],
|
|
set: { rank: item.rank, lastFetchedAt: now },
|
|
})
|
|
.run();
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Fetch seasons from the DB, building the Season[] structure. */
|
|
function fetchSeasonsFromDb(titleId: string): Season[] {
|
|
const seasonRows = db
|
|
.select()
|
|
.from(seasons)
|
|
.where(eq(seasons.titleId, titleId))
|
|
.orderBy(seasons.seasonNumber)
|
|
.all();
|
|
|
|
if (seasonRows.length === 0) return [];
|
|
|
|
const seasonIds = seasonRows.map((s) => s.id);
|
|
const allEps = db
|
|
.select()
|
|
.from(episodes)
|
|
.where(inArray(episodes.seasonId, seasonIds))
|
|
.orderBy(episodes.seasonId, episodes.episodeNumber)
|
|
.all();
|
|
|
|
const epsBySeason = new Map<string, Episode[]>();
|
|
for (const ep of allEps) {
|
|
const arr = epsBySeason.get(ep.seasonId) ?? [];
|
|
arr.push({
|
|
id: ep.id,
|
|
episodeNumber: ep.episodeNumber,
|
|
name: ep.name,
|
|
overview: ep.overview,
|
|
stillPath: tmdbImageUrl(ep.stillPath, "w1280", "stills"),
|
|
airDate: ep.airDate,
|
|
runtimeMinutes: ep.runtimeMinutes,
|
|
});
|
|
epsBySeason.set(ep.seasonId, arr);
|
|
}
|
|
|
|
return seasonRows.map((s) => ({
|
|
id: s.id,
|
|
seasonNumber: s.seasonNumber,
|
|
name: s.name,
|
|
episodes: epsBySeason.get(s.id) ?? [],
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Ensure a TV title is fully hydrated (seasons/episodes fetched from TMDB).
|
|
* Returns the hydrated seasons data.
|
|
*/
|
|
export async function ensureTvHydrated(
|
|
titleId: string,
|
|
tmdbId: number,
|
|
): Promise<Season[]> {
|
|
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
|
if (!title || title.type !== "tv") return [];
|
|
|
|
// Shell title: fetch details + children
|
|
if (!title.lastFetchedAt) {
|
|
try {
|
|
const show = await getTvDetails(tmdbId);
|
|
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, titleId))
|
|
.run();
|
|
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
|
} catch (err) {
|
|
log.debug(`Failed to hydrate shell TV title ${titleId}:`, err);
|
|
}
|
|
}
|
|
|
|
let result = fetchSeasonsFromDb(titleId);
|
|
|
|
// Retry hydration when seasons are still missing
|
|
if (result.length === 0) {
|
|
try {
|
|
const show = await getTvDetails(tmdbId);
|
|
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
|
result = fetchSeasonsFromDb(titleId);
|
|
} catch (err) {
|
|
log.debug(
|
|
`Failed to backfill missing seasons for title ${titleId}:`,
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export async function getTitleWithChildren(id: string): Promise<{
|
|
title: ResolvedTitle;
|
|
seasons: Season[];
|
|
needsHydration: boolean;
|
|
availability: AvailabilityOffer[];
|
|
cast: CastMember[];
|
|
} | null> {
|
|
let title = db.select().from(titles).where(eq(titles.id, id)).get();
|
|
if (!title) return null;
|
|
|
|
// For shell TV titles, skip blocking hydration — let Suspense stream it
|
|
const needsTvHydration =
|
|
title.type === "tv" &&
|
|
(!title.lastFetchedAt || fetchSeasonsFromDb(id).length === 0);
|
|
|
|
// If this is a shell movie title, fetch full details now (movies are fast)
|
|
if (title.type === "movie" && !title.lastFetchedAt) {
|
|
try {
|
|
const movie = await getMovieDetails(title.tmdbId);
|
|
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: new Date(),
|
|
})
|
|
.where(eq(titles.id, id))
|
|
.run();
|
|
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
|
} catch (err) {
|
|
log.debug(`Failed to hydrate shell movie title ${id}:`, err);
|
|
}
|
|
}
|
|
|
|
// For already-hydrated TV titles, fetch seasons from DB directly
|
|
const titleSeasons = needsTvHydration ? [] : fetchSeasonsFromDb(id);
|
|
|
|
const availability = db
|
|
.select()
|
|
.from(availabilityOffers)
|
|
.where(eq(availabilityOffers.titleId, title.id))
|
|
.all()
|
|
.map(
|
|
(a): AvailabilityOffer => ({
|
|
providerId: a.providerId,
|
|
providerName: a.providerName,
|
|
logoPath: tmdbImageUrl(a.logoPath, "w92"),
|
|
offerType: a.offerType,
|
|
}),
|
|
);
|
|
|
|
// Color extraction — use cached palette if available, otherwise fire-and-forget
|
|
const palette = parseColorPalette(title.colorPalette);
|
|
if (!palette && title.posterPath) {
|
|
extractAndStoreColors(title.id, title.posterPath).catch(() => {});
|
|
}
|
|
|
|
const resolvedTitle: ResolvedTitle = {
|
|
id: title.id,
|
|
tmdbId: title.tmdbId,
|
|
type: title.type as "movie" | "tv",
|
|
title: title.title,
|
|
originalTitle: title.originalTitle,
|
|
overview: title.overview,
|
|
releaseDate: title.releaseDate,
|
|
firstAirDate: title.firstAirDate,
|
|
posterPath: tmdbImageUrl(title.posterPath, "w500"),
|
|
backdropPath: tmdbImageUrl(title.backdropPath, "w1280"),
|
|
popularity: title.popularity,
|
|
voteAverage: title.voteAverage,
|
|
voteCount: title.voteCount,
|
|
status: title.status,
|
|
colorPalette: palette,
|
|
trailerVideoKey: title.trailerVideoKey,
|
|
};
|
|
|
|
const cast = getCastForTitle(id);
|
|
|
|
return {
|
|
title: resolvedTitle,
|
|
seasons: titleSeasons,
|
|
needsHydration: needsTvHydration,
|
|
availability,
|
|
cast,
|
|
};
|
|
}
|
|
|
|
export function pickBestTrailer(videos: TmdbVideo[]): string | null {
|
|
let candidates = videos.filter((v) => v.site === "YouTube");
|
|
if (candidates.length === 0) return null;
|
|
|
|
// Prefer English, fall back to any language
|
|
const english = candidates.filter((v) => v.iso_639_1 === "en");
|
|
if (english.length > 0) candidates = english;
|
|
|
|
const byDate = (a: TmdbVideo, b: TmdbVideo) =>
|
|
(b.published_at ?? "").localeCompare(a.published_at ?? "");
|
|
|
|
// Tier 1: official trailers
|
|
const officialTrailers = candidates
|
|
.filter((v) => v.official && v.type === "Trailer")
|
|
.sort(byDate);
|
|
if (officialTrailers.length > 0) return officialTrailers[0].key;
|
|
|
|
// Tier 2: any trailer
|
|
const trailers = candidates.filter((v) => v.type === "Trailer").sort(byDate);
|
|
if (trailers.length > 0) return trailers[0].key;
|
|
|
|
// Tier 3: teasers
|
|
const teasers = candidates.filter((v) => v.type === "Teaser").sort(byDate);
|
|
if (teasers.length > 0) return teasers[0].key;
|
|
|
|
return null;
|
|
}
|
|
|
|
export async function refreshTrailer(titleId: string) {
|
|
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
|
if (!title) return;
|
|
|
|
try {
|
|
const response = await getVideos(title.tmdbId, title.type);
|
|
const key = pickBestTrailer(response.results);
|
|
db.update(titles)
|
|
.set({ trailerVideoKey: key })
|
|
.where(eq(titles.id, titleId))
|
|
.run();
|
|
log.debug(
|
|
`Trailer for "${title.title}": ${key ? `YouTube ${key}` : "none found"}`,
|
|
);
|
|
} catch (err) {
|
|
log.debug(`Failed to fetch trailer for title ${titleId}:`, err);
|
|
}
|
|
}
|
|
|
|
function delay(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|