mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Optimize performance: batch DB ops, N+1 fixes, Suspense streaming, and Jotai best practices
- 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>
This commit is contained in:
+245
-149
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { persons, titleCast, titles } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
@@ -17,79 +17,67 @@ const NOTABLE_DEPARTMENTS = new Set([
|
||||
"Executive Producer",
|
||||
]);
|
||||
|
||||
function upsertPerson(
|
||||
tmdbId: number,
|
||||
name: string,
|
||||
profilePath: string | null,
|
||||
popularity?: number,
|
||||
): string {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.tmdbId, tmdbId))
|
||||
.get();
|
||||
if (existing) return existing.id;
|
||||
|
||||
const row = db
|
||||
.insert(persons)
|
||||
.values({
|
||||
tmdbId,
|
||||
name,
|
||||
profilePath,
|
||||
popularity: popularity ?? null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
if (row) return row.id;
|
||||
|
||||
// Race condition: another insert beat us
|
||||
const found = db
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.tmdbId, tmdbId))
|
||||
.get();
|
||||
// biome-ignore lint/style/noNonNullAssertion: guaranteed by onConflictDoNothing + prior existence check
|
||||
return found!.id;
|
||||
interface PersonData {
|
||||
tmdbId: number;
|
||||
name: string;
|
||||
profilePath: string | null;
|
||||
popularity?: number;
|
||||
}
|
||||
|
||||
function upsertTitleCast(
|
||||
titleId: string,
|
||||
personId: string,
|
||||
character: string | null,
|
||||
department: string,
|
||||
job: string | null,
|
||||
displayOrder: number,
|
||||
episodeCount: number | null,
|
||||
) {
|
||||
const now = new Date();
|
||||
db.insert(titleCast)
|
||||
.values({
|
||||
titleId,
|
||||
personId,
|
||||
character,
|
||||
department,
|
||||
job,
|
||||
displayOrder,
|
||||
episodeCount,
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
titleCast.titleId,
|
||||
titleCast.personId,
|
||||
titleCast.department,
|
||||
titleCast.character,
|
||||
],
|
||||
set: {
|
||||
job,
|
||||
displayOrder,
|
||||
episodeCount,
|
||||
lastFetchedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
function batchUpsertPersons(people: PersonData[]): Map<number, string> {
|
||||
if (people.length === 0) return new Map();
|
||||
|
||||
// Deduplicate by tmdbId
|
||||
const uniqueByTmdbId = new Map<number, PersonData>();
|
||||
for (const p of people) {
|
||||
if (!uniqueByTmdbId.has(p.tmdbId)) uniqueByTmdbId.set(p.tmdbId, p);
|
||||
}
|
||||
const uniquePeople = [...uniqueByTmdbId.values()];
|
||||
const tmdbIds = uniquePeople.map((p) => p.tmdbId);
|
||||
|
||||
// Batch prefetch existing persons (1 query)
|
||||
const existing = db
|
||||
.select({ id: persons.id, tmdbId: persons.tmdbId })
|
||||
.from(persons)
|
||||
.where(inArray(persons.tmdbId, tmdbIds))
|
||||
.all();
|
||||
const idMap = new Map<number, string>(existing.map((p) => [p.tmdbId, p.id]));
|
||||
|
||||
// Insert only new persons in a transaction
|
||||
const newPeople = uniquePeople.filter((p) => !idMap.has(p.tmdbId));
|
||||
if (newPeople.length > 0) {
|
||||
db.transaction((tx) => {
|
||||
for (const p of newPeople) {
|
||||
const row = tx
|
||||
.insert(persons)
|
||||
.values({
|
||||
tmdbId: p.tmdbId,
|
||||
name: p.name,
|
||||
profilePath: p.profilePath,
|
||||
popularity: p.popularity ?? null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get();
|
||||
if (row) idMap.set(p.tmdbId, row.id);
|
||||
}
|
||||
});
|
||||
|
||||
// One fallback query for any that conflicted
|
||||
const stillMissing = newPeople
|
||||
.filter((p) => !idMap.has(p.tmdbId))
|
||||
.map((p) => p.tmdbId);
|
||||
if (stillMissing.length > 0) {
|
||||
const fallbacks = db
|
||||
.select({ id: persons.id, tmdbId: persons.tmdbId })
|
||||
.from(persons)
|
||||
.where(inArray(persons.tmdbId, stillMissing))
|
||||
.all();
|
||||
for (const f of fallbacks) idMap.set(f.tmdbId, f.id);
|
||||
}
|
||||
}
|
||||
|
||||
return idMap;
|
||||
}
|
||||
|
||||
export async function refreshCredits(titleId: string) {
|
||||
@@ -101,105 +89,213 @@ export async function refreshCredits(titleId: string) {
|
||||
try {
|
||||
if (title.type === "movie") {
|
||||
const credits = await getMovieCredits(title.tmdbId);
|
||||
|
||||
// Top 20 cast
|
||||
const castSlice = credits.cast.slice(0, 20);
|
||||
for (let i = 0; i < castSlice.length; i++) {
|
||||
const c = castSlice[i];
|
||||
const personId = upsertPerson(
|
||||
c.id,
|
||||
c.name,
|
||||
c.profile_path,
|
||||
c.popularity,
|
||||
);
|
||||
upsertTitleCast(
|
||||
titleId,
|
||||
personId,
|
||||
c.character,
|
||||
"Acting",
|
||||
null,
|
||||
i,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// Notable crew
|
||||
// Collect notable crew
|
||||
const seenCrew = new Set<string>();
|
||||
let crewOrder = 100;
|
||||
const notableCrew: typeof credits.crew = [];
|
||||
for (const c of credits.crew) {
|
||||
if (!NOTABLE_DEPARTMENTS.has(c.job)) continue;
|
||||
const key = `${c.id}-${c.job}`;
|
||||
if (seenCrew.has(key)) continue;
|
||||
seenCrew.add(key);
|
||||
|
||||
const personId = upsertPerson(
|
||||
c.id,
|
||||
c.name,
|
||||
c.profile_path,
|
||||
c.popularity,
|
||||
);
|
||||
upsertTitleCast(
|
||||
titleId,
|
||||
personId,
|
||||
null,
|
||||
c.department,
|
||||
c.job,
|
||||
crewOrder++,
|
||||
null,
|
||||
);
|
||||
notableCrew.push(c);
|
||||
}
|
||||
|
||||
// Batch upsert all people at once
|
||||
const allPeople: PersonData[] = [
|
||||
...castSlice.map((c) => ({
|
||||
tmdbId: c.id,
|
||||
name: c.name,
|
||||
profilePath: c.profile_path,
|
||||
popularity: c.popularity,
|
||||
})),
|
||||
...notableCrew.map((c) => ({
|
||||
tmdbId: c.id,
|
||||
name: c.name,
|
||||
profilePath: c.profile_path,
|
||||
popularity: c.popularity,
|
||||
})),
|
||||
];
|
||||
const personIds = batchUpsertPersons(allPeople);
|
||||
|
||||
// Batch insert titleCast rows
|
||||
db.transaction((tx) => {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < castSlice.length; i++) {
|
||||
const c = castSlice[i];
|
||||
const personId = personIds.get(c.id);
|
||||
if (!personId) continue;
|
||||
tx.insert(titleCast)
|
||||
.values({
|
||||
titleId,
|
||||
personId,
|
||||
character: c.character,
|
||||
department: "Acting",
|
||||
job: null,
|
||||
displayOrder: i,
|
||||
episodeCount: null,
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
titleCast.titleId,
|
||||
titleCast.personId,
|
||||
titleCast.department,
|
||||
titleCast.character,
|
||||
],
|
||||
set: {
|
||||
job: null,
|
||||
displayOrder: i,
|
||||
episodeCount: null,
|
||||
lastFetchedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
let crewOrder = 100;
|
||||
for (const c of notableCrew) {
|
||||
const personId = personIds.get(c.id);
|
||||
if (!personId) continue;
|
||||
tx.insert(titleCast)
|
||||
.values({
|
||||
titleId,
|
||||
personId,
|
||||
character: null,
|
||||
department: c.department,
|
||||
job: c.job,
|
||||
displayOrder: crewOrder,
|
||||
episodeCount: null,
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
titleCast.titleId,
|
||||
titleCast.personId,
|
||||
titleCast.department,
|
||||
titleCast.character,
|
||||
],
|
||||
set: {
|
||||
job: c.job,
|
||||
displayOrder: crewOrder,
|
||||
episodeCount: null,
|
||||
lastFetchedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
crewOrder++;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const credits = await getTvAggregateCredits(title.tmdbId);
|
||||
|
||||
// Top 20 cast
|
||||
const castSlice = credits.cast.slice(0, 20);
|
||||
for (let i = 0; i < castSlice.length; i++) {
|
||||
const c = castSlice[i];
|
||||
const personId = upsertPerson(
|
||||
c.id,
|
||||
c.name,
|
||||
c.profile_path,
|
||||
c.popularity,
|
||||
);
|
||||
const character = c.roles?.[0]?.character ?? null;
|
||||
upsertTitleCast(
|
||||
titleId,
|
||||
personId,
|
||||
character,
|
||||
"Acting",
|
||||
null,
|
||||
i,
|
||||
c.total_episode_count,
|
||||
);
|
||||
}
|
||||
|
||||
// Notable crew
|
||||
// Collect notable crew
|
||||
const seenCrew = new Set<string>();
|
||||
let crewOrder = 100;
|
||||
const notableCrew: Array<{
|
||||
person: (typeof credits.crew)[0];
|
||||
job: string;
|
||||
episodeCount: number;
|
||||
}> = [];
|
||||
for (const c of credits.crew) {
|
||||
for (const j of c.jobs) {
|
||||
if (!NOTABLE_DEPARTMENTS.has(j.job)) continue;
|
||||
const key = `${c.id}-${j.job}`;
|
||||
if (seenCrew.has(key)) continue;
|
||||
seenCrew.add(key);
|
||||
|
||||
const personId = upsertPerson(
|
||||
c.id,
|
||||
c.name,
|
||||
c.profile_path,
|
||||
c.popularity,
|
||||
);
|
||||
upsertTitleCast(
|
||||
titleId,
|
||||
personId,
|
||||
null,
|
||||
c.department,
|
||||
j.job,
|
||||
crewOrder++,
|
||||
j.episode_count,
|
||||
);
|
||||
notableCrew.push({
|
||||
person: c,
|
||||
job: j.job,
|
||||
episodeCount: j.episode_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Batch upsert all people at once
|
||||
const allPeople: PersonData[] = [
|
||||
...castSlice.map((c) => ({
|
||||
tmdbId: c.id,
|
||||
name: c.name,
|
||||
profilePath: c.profile_path,
|
||||
popularity: c.popularity,
|
||||
})),
|
||||
...notableCrew.map((c) => ({
|
||||
tmdbId: c.person.id,
|
||||
name: c.person.name,
|
||||
profilePath: c.person.profile_path,
|
||||
popularity: c.person.popularity,
|
||||
})),
|
||||
];
|
||||
const personIds = batchUpsertPersons(allPeople);
|
||||
|
||||
// Batch insert titleCast rows
|
||||
db.transaction((tx) => {
|
||||
const now = new Date();
|
||||
for (let i = 0; i < castSlice.length; i++) {
|
||||
const c = castSlice[i];
|
||||
const personId = personIds.get(c.id);
|
||||
if (!personId) continue;
|
||||
const character = c.roles?.[0]?.character ?? null;
|
||||
tx.insert(titleCast)
|
||||
.values({
|
||||
titleId,
|
||||
personId,
|
||||
character,
|
||||
department: "Acting",
|
||||
job: null,
|
||||
displayOrder: i,
|
||||
episodeCount: c.total_episode_count,
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
titleCast.titleId,
|
||||
titleCast.personId,
|
||||
titleCast.department,
|
||||
titleCast.character,
|
||||
],
|
||||
set: {
|
||||
job: null,
|
||||
displayOrder: i,
|
||||
episodeCount: c.total_episode_count,
|
||||
lastFetchedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
let crewOrder = 100;
|
||||
for (const c of notableCrew) {
|
||||
const personId = personIds.get(c.person.id);
|
||||
if (!personId) continue;
|
||||
tx.insert(titleCast)
|
||||
.values({
|
||||
titleId,
|
||||
personId,
|
||||
character: null,
|
||||
department: c.person.department,
|
||||
job: c.job,
|
||||
displayOrder: crewOrder,
|
||||
episodeCount: c.episodeCount,
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
titleCast.titleId,
|
||||
titleCast.personId,
|
||||
titleCast.department,
|
||||
titleCast.character,
|
||||
],
|
||||
set: {
|
||||
job: c.job,
|
||||
displayOrder: crewOrder,
|
||||
episodeCount: c.episodeCount,
|
||||
lastFetchedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
crewOrder++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
log.debug(`Credits refreshed for "${title.title}"`);
|
||||
|
||||
+181
-182
@@ -442,94 +442,61 @@ export async function refreshRecommendations(titleId: string) {
|
||||
`Fetched ${recs.results.length} recommendations and ${similar.results.length} similar for title ${titleId}`,
|
||||
);
|
||||
|
||||
// Process recommendations
|
||||
// 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") continue;
|
||||
|
||||
// Minimal upsert of the recommended title
|
||||
const existing = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, r.id))
|
||||
.get();
|
||||
let recTitleId: string;
|
||||
if (existing) {
|
||||
recTitleId = existing.id;
|
||||
} else {
|
||||
const row = 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 = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, r.id))
|
||||
.get();
|
||||
if (!found) continue;
|
||||
recTitleId = found.id;
|
||||
} else {
|
||||
recTitleId = row.id;
|
||||
}
|
||||
}
|
||||
|
||||
db.insert(titleRecommendations)
|
||||
.values({
|
||||
titleId,
|
||||
recommendedTitleId: recTitleId,
|
||||
if (type === "movie" || type === "tv") {
|
||||
allItems.push({
|
||||
result: r,
|
||||
type,
|
||||
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;
|
||||
if (type === "movie" || type === "tv") {
|
||||
allItems.push({ result: r, type, source: "tmdb_similar", rank: i + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, r.id))
|
||||
.get();
|
||||
let recTitleId: string;
|
||||
if (existing) {
|
||||
recTitleId = existing.id;
|
||||
} else {
|
||||
const row = db
|
||||
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,
|
||||
type: item.type,
|
||||
title: r.title ?? r.name ?? "Unknown",
|
||||
originalTitle: r.original_title ?? r.original_name,
|
||||
overview: r.overview,
|
||||
@@ -545,52 +512,104 @@ export async function refreshRecommendations(titleId: string) {
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get();
|
||||
if (!row) {
|
||||
const found = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, r.id))
|
||||
.get();
|
||||
if (!found) continue;
|
||||
recTitleId = found.id;
|
||||
} else {
|
||||
recTitleId = row.id;
|
||||
}
|
||||
if (row) titleIdMap.set(r.id, row.id);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTitleWithChildren(id: string): Promise<{
|
||||
title: ResolvedTitle;
|
||||
seasons: Season[];
|
||||
availability: AvailabilityOffer[];
|
||||
cast: CastMember[];
|
||||
} | null> {
|
||||
let title = db.select().from(titles).where(eq(titles.id, id)).get();
|
||||
if (!title) return null;
|
||||
/** 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 this is a shell TV title, fetch full details now
|
||||
if (title.type === "tv" && !title.lastFetchedAt) {
|
||||
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(title.tmdbId);
|
||||
const show = await getTvDetails(tmdbId);
|
||||
db.update(titles)
|
||||
.set({
|
||||
overview: show.overview,
|
||||
@@ -599,16 +618,49 @@ export async function getTitleWithChildren(id: string): Promise<{
|
||||
status: show.status,
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.where(eq(titles.id, id))
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
|
||||
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
||||
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
||||
} catch (err) {
|
||||
log.debug(`Failed to hydrate shell TV title ${id}:`, err);
|
||||
log.debug(`Failed to hydrate shell TV title ${titleId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a shell movie title, fetch full details now
|
||||
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);
|
||||
@@ -634,67 +686,8 @@ export async function getTitleWithChildren(id: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
let titleSeasons: Season[] = [];
|
||||
|
||||
if (title.type === "tv") {
|
||||
let seasonRows = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, title.id))
|
||||
.orderBy(seasons.seasonNumber)
|
||||
.all();
|
||||
|
||||
// Retry hydration when a TV title exists but no seasons were stored.
|
||||
if (seasonRows.length === 0) {
|
||||
try {
|
||||
const show = await getTvDetails(title.tmdbId);
|
||||
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
|
||||
seasonRows = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, title.id))
|
||||
.orderBy(seasons.seasonNumber)
|
||||
.all();
|
||||
} catch (err) {
|
||||
log.debug(`Failed to backfill missing seasons for title ${id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch fetch all episodes for all seasons (1 query)
|
||||
const seasonIds = seasonRows.map((s) => s.id);
|
||||
const allEps =
|
||||
seasonIds.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(inArray(episodes.seasonId, seasonIds))
|
||||
.orderBy(episodes.seasonId, episodes.episodeNumber)
|
||||
.all()
|
||||
: [];
|
||||
|
||||
// Group episodes by season
|
||||
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);
|
||||
}
|
||||
|
||||
titleSeasons = seasonRows.map((s) => ({
|
||||
id: s.id,
|
||||
seasonNumber: s.seasonNumber,
|
||||
name: s.name,
|
||||
episodes: epsBySeason.get(s.id) ?? [],
|
||||
}));
|
||||
}
|
||||
// For already-hydrated TV titles, fetch seasons from DB directly
|
||||
const titleSeasons = needsTvHydration ? [] : fetchSeasonsFromDb(id);
|
||||
|
||||
const availability = db
|
||||
.select()
|
||||
@@ -737,7 +730,13 @@ export async function getTitleWithChildren(id: string): Promise<{
|
||||
|
||||
const cast = getCastForTitle(id);
|
||||
|
||||
return { title: resolvedTitle, seasons: titleSeasons, availability, cast };
|
||||
return {
|
||||
title: resolvedTitle,
|
||||
seasons: titleSeasons,
|
||||
needsHydration: needsTvHydration,
|
||||
availability,
|
||||
cast,
|
||||
};
|
||||
}
|
||||
|
||||
export function pickBestTrailer(videos: TmdbVideo[]): string | null {
|
||||
|
||||
+65
-46
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { persons, titleCast, titles } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
@@ -173,58 +173,77 @@ export async function fetchFullFilmography(
|
||||
|
||||
const credits = await getPersonCombinedCredits(person.tmdbId);
|
||||
|
||||
const results: PersonCredit[] = [];
|
||||
// Filter to valid cast entries
|
||||
const validCast = credits.cast.filter(
|
||||
(c) => c.media_type === "movie" || c.media_type === "tv",
|
||||
);
|
||||
if (validCast.length === 0) return [];
|
||||
|
||||
for (const c of credits.cast) {
|
||||
const type = c.media_type;
|
||||
if (type !== "movie" && type !== "tv") continue;
|
||||
// Batch prefetch existing titles (1 query)
|
||||
const tmdbIds = [...new Set(validCast.map((c) => c.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]),
|
||||
);
|
||||
|
||||
// Create shell title if not in DB
|
||||
const existing = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, c.id))
|
||||
.get();
|
||||
let titleId: string;
|
||||
if (existing) {
|
||||
titleId = existing.id;
|
||||
} else {
|
||||
const row = db
|
||||
.insert(titles)
|
||||
.values({
|
||||
tmdbId: c.id,
|
||||
type,
|
||||
title: c.title ?? c.name ?? "Unknown",
|
||||
overview: c.overview,
|
||||
releaseDate: c.release_date,
|
||||
firstAirDate: c.first_air_date,
|
||||
posterPath: c.poster_path,
|
||||
backdropPath: c.backdrop_path,
|
||||
popularity: c.popularity,
|
||||
voteAverage: c.vote_average,
|
||||
voteCount: c.vote_count,
|
||||
lastFetchedAt: null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get();
|
||||
if (!row) {
|
||||
const found = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, c.id))
|
||||
// Batch insert missing titles in a transaction
|
||||
const newCast = validCast.filter((c) => !titleIdMap.has(c.id));
|
||||
if (newCast.length > 0) {
|
||||
const insertedTmdbIds = new Set<number>();
|
||||
db.transaction((tx) => {
|
||||
for (const c of newCast) {
|
||||
if (insertedTmdbIds.has(c.id)) continue;
|
||||
insertedTmdbIds.add(c.id);
|
||||
const row = tx
|
||||
.insert(titles)
|
||||
.values({
|
||||
tmdbId: c.id,
|
||||
type: c.media_type as "movie" | "tv",
|
||||
title: c.title ?? c.name ?? "Unknown",
|
||||
overview: c.overview,
|
||||
releaseDate: c.release_date,
|
||||
firstAirDate: c.first_air_date,
|
||||
posterPath: c.poster_path,
|
||||
backdropPath: c.backdrop_path,
|
||||
popularity: c.popularity,
|
||||
voteAverage: c.vote_average,
|
||||
voteCount: c.vote_count,
|
||||
lastFetchedAt: null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get();
|
||||
if (!found) continue;
|
||||
titleId = found.id;
|
||||
} else {
|
||||
titleId = row.id;
|
||||
if (row) titleIdMap.set(c.id, row.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// One fallback query for any that conflicted
|
||||
const stillMissing = [...insertedTmdbIds].filter(
|
||||
(id) => !titleIdMap.has(id),
|
||||
);
|
||||
if (stillMissing.length > 0) {
|
||||
const fallbacks = db
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
// Build results from map (0 queries)
|
||||
const results: PersonCredit[] = [];
|
||||
for (const c of validCast) {
|
||||
const tid = titleIdMap.get(c.id);
|
||||
if (!tid) continue;
|
||||
results.push({
|
||||
titleId,
|
||||
titleId: tid,
|
||||
tmdbId: c.id,
|
||||
type,
|
||||
type: c.media_type as "movie" | "tv",
|
||||
title: c.title ?? c.name ?? "Unknown",
|
||||
posterPath: tmdbImageUrl(c.poster_path, "w500"),
|
||||
releaseDate: c.release_date ?? null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { access, constants, readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { count, desc, inArray } from "drizzle-orm";
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { cronRuns, episodes, titles, user } from "@/lib/db/schema";
|
||||
import { listBackups } from "@/lib/services/backup";
|
||||
@@ -151,20 +151,17 @@ function getJobsHealth(): SystemHealthData["jobs"] {
|
||||
const schedules = getJobSchedules();
|
||||
const scheduleMap = new Map(schedules.map((s) => [s.jobName, s]));
|
||||
|
||||
// Batch fetch the latest cron run for each job (1 query)
|
||||
const allLatestRuns = db
|
||||
.select()
|
||||
.from(cronRuns)
|
||||
.where(inArray(cronRuns.jobName, JOB_NAMES))
|
||||
.orderBy(desc(cronRuns.startedAt))
|
||||
.all();
|
||||
|
||||
// Keep only the most recent run per job
|
||||
const latestByJob = new Map<string, (typeof allLatestRuns)[0]>();
|
||||
for (const run of allLatestRuns) {
|
||||
if (!latestByJob.has(run.jobName)) {
|
||||
latestByJob.set(run.jobName, run);
|
||||
}
|
||||
// Fetch only the latest cron run per job (index-optimized LIMIT 1 each)
|
||||
const latestByJob = new Map<string, typeof cronRuns.$inferSelect>();
|
||||
for (const jobName of JOB_NAMES) {
|
||||
const latest = db
|
||||
.select()
|
||||
.from(cronRuns)
|
||||
.where(eq(cronRuns.jobName, jobName))
|
||||
.orderBy(desc(cronRuns.startedAt))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (latest) latestByJob.set(jobName, latest);
|
||||
}
|
||||
|
||||
return JOB_NAMES.map((jobName) => {
|
||||
@@ -202,24 +199,30 @@ async function getImageCacheHealth(): Promise<SystemHealthData["imageCache"]> {
|
||||
let totalSizeBytes = 0;
|
||||
let imageCount = 0;
|
||||
|
||||
for (const category of categoryNames) {
|
||||
const dir = path.join(CACHE_DIR, category);
|
||||
try {
|
||||
const files = await readdir(dir);
|
||||
let sizeBytes = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
const s = await stat(path.join(dir, file));
|
||||
if (s.isFile()) sizeBytes += s.size;
|
||||
} catch {}
|
||||
await Promise.all(
|
||||
categoryNames.map(async (category) => {
|
||||
const dir = path.join(CACHE_DIR, category);
|
||||
try {
|
||||
const files = await readdir(dir);
|
||||
const sizes = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const s = await stat(path.join(dir, file));
|
||||
return s.isFile() ? s.size : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const sizeBytes = sizes.reduce((sum, s) => sum + s, 0);
|
||||
categories[category] = { count: files.length, sizeBytes };
|
||||
totalSizeBytes += sizeBytes;
|
||||
imageCount += files.length;
|
||||
} catch {
|
||||
categories[category] = { count: 0, sizeBytes: 0 };
|
||||
}
|
||||
categories[category] = { count: files.length, sizeBytes };
|
||||
totalSizeBytes += sizeBytes;
|
||||
imageCount += files.length;
|
||||
} catch {
|
||||
categories[category] = { count: 0, sizeBytes: 0 };
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { enabled: true, totalSizeBytes, imageCount, categories };
|
||||
}
|
||||
|
||||
+122
-6
@@ -108,6 +108,120 @@ export function logEpisodeWatch(
|
||||
checkAllEpisodesWatched(userId, titleId);
|
||||
}
|
||||
|
||||
export function logEpisodeWatchBatch(
|
||||
userId: string,
|
||||
episodeIds: string[],
|
||||
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||
) {
|
||||
if (episodeIds.length === 0) return;
|
||||
|
||||
db.transaction((tx) => {
|
||||
const now = new Date();
|
||||
|
||||
// Batch INSERT all watch records
|
||||
for (const episodeId of episodeIds) {
|
||||
tx.insert(userEpisodeWatches)
|
||||
.values({ userId, episodeId, watchedAt: now, source })
|
||||
.run();
|
||||
}
|
||||
|
||||
// Resolve episode → season → title hierarchy with batch queries
|
||||
const eps = tx
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(inArray(episodes.id, episodeIds))
|
||||
.all();
|
||||
if (eps.length === 0) return;
|
||||
|
||||
const seasonIds = [...new Set(eps.map((e) => e.seasonId))];
|
||||
const seasonRows = tx
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(inArray(seasons.id, seasonIds))
|
||||
.all();
|
||||
if (seasonRows.length === 0) return;
|
||||
|
||||
const titleId = seasonRows[0].titleId;
|
||||
|
||||
// Set status to in_progress if not already set
|
||||
const existing = tx
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
const statusNow = new Date();
|
||||
tx.insert(userTitleStatus)
|
||||
.values({
|
||||
userId,
|
||||
titleId,
|
||||
status: "in_progress",
|
||||
addedAt: statusNow,
|
||||
updatedAt: statusNow,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userTitleStatus.userId, userTitleStatus.titleId],
|
||||
set: { status: "in_progress", updatedAt: statusNow },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
// Check completion once (not per-episode)
|
||||
const allSeasons = tx
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
if (allSeasons.length === 0) return;
|
||||
|
||||
const allSeasonIds = allSeasons.map((s) => s.id);
|
||||
const allEps = tx
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(inArray(episodes.seasonId, allSeasonIds))
|
||||
.all();
|
||||
const totalEpisodes = allEps.length;
|
||||
if (totalEpisodes === 0) return;
|
||||
|
||||
const allEpIds = allEps.map((ep) => ep.id);
|
||||
const [watchCount] = tx
|
||||
.select({
|
||||
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
|
||||
})
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
inArray(userEpisodeWatches.episodeId, allEpIds),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
if (watchCount.count >= totalEpisodes) {
|
||||
const completeNow = new Date();
|
||||
tx.insert(userTitleStatus)
|
||||
.values({
|
||||
userId,
|
||||
titleId,
|
||||
status: "completed",
|
||||
addedAt: completeNow,
|
||||
updatedAt: completeNow,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userTitleStatus.userId, userTitleStatus.titleId],
|
||||
set: { status: "completed", updatedAt: completeNow },
|
||||
})
|
||||
.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function markAllEpisodesWatched(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
@@ -151,13 +265,15 @@ export function markAllEpisodesWatched(
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
for (const ep of allEps) {
|
||||
if (!existingWatches.has(ep.id)) {
|
||||
db.insert(userEpisodeWatches)
|
||||
.values({ userId, episodeId: ep.id, watchedAt: now, source })
|
||||
.run();
|
||||
db.transaction((tx) => {
|
||||
for (const ep of allEps) {
|
||||
if (!existingWatches.has(ep.id)) {
|
||||
tx.insert(userEpisodeWatches)
|
||||
.values({ userId, episodeId: ep.id, watchedAt: now, source })
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setTitleStatus(userId, titleId, "completed", source);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user