mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 03:55:38 -04:00
Replace tmdb-* URL routing with resolveTitle/resolvePerson server actions
- Add `resolveTitle` and `resolvePerson` server actions that import
from TMDB and return the internal DB id
- Remove `tmdb-{id}-{type}` URL pattern and server-side redirect logic
from TitleDetailPage and PersonDetailPage
- Rename `importTitle` → `getOrFetchTitleByTmdbId`; add `getOrFetchTitle`
combining fetch + children lookup
- Update HeroBanner, TitleCard, and CommandPalette to call resolve
actions client-side before pushing to router
- Batch availability offer inserts into a single transaction
This commit is contained in:
@@ -20,8 +20,26 @@ export async function refreshAvailability(titleId: string) {
|
||||
const now = new Date();
|
||||
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
|
||||
|
||||
// Collect all offer rows, then batch insert in a single transaction
|
||||
const allOfferRows: (typeof availabilityOffers.$inferInsert)[] = [];
|
||||
for (const offerType of offerTypes) {
|
||||
const providers = us[offerType];
|
||||
if (!providers) continue;
|
||||
for (const p of providers) {
|
||||
allOfferRows.push({
|
||||
titleId,
|
||||
region: "US",
|
||||
providerId: p.provider_id,
|
||||
providerName: p.provider_name,
|
||||
logoPath: p.logo_path,
|
||||
offerType,
|
||||
link: us.link ?? null,
|
||||
lastFetchedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
db.transaction((tx) => {
|
||||
// Delete existing offers for this title+region
|
||||
tx.delete(availabilityOffers)
|
||||
.where(
|
||||
and(
|
||||
@@ -31,25 +49,11 @@ export async function refreshAvailability(titleId: string) {
|
||||
)
|
||||
.run();
|
||||
|
||||
for (const offerType of offerTypes) {
|
||||
const providers = us[offerType];
|
||||
if (!providers) continue;
|
||||
|
||||
for (const p of providers) {
|
||||
tx.insert(availabilityOffers)
|
||||
.values({
|
||||
titleId,
|
||||
region: "US",
|
||||
providerId: p.provider_id,
|
||||
providerName: p.provider_name,
|
||||
logoPath: p.logo_path,
|
||||
offerType,
|
||||
link: us.link ?? null,
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
if (allOfferRows.length > 0) {
|
||||
tx.insert(availabilityOffers)
|
||||
.values(allOfferRows)
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+108
-136
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { persons, titleCast, titles } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
@@ -119,73 +119,59 @@ export async function refreshCredits(titleId: string) {
|
||||
];
|
||||
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++;
|
||||
}
|
||||
});
|
||||
// Collect all titleCast rows (cast + crew) and batch insert
|
||||
const now = new Date();
|
||||
const allCastRows: (typeof titleCast.$inferInsert)[] = [];
|
||||
for (let i = 0; i < castSlice.length; i++) {
|
||||
const c = castSlice[i];
|
||||
const personId = personIds.get(c.id);
|
||||
if (!personId) continue;
|
||||
allCastRows.push({
|
||||
titleId,
|
||||
personId,
|
||||
character: c.character,
|
||||
department: "Acting",
|
||||
job: null,
|
||||
displayOrder: i,
|
||||
episodeCount: null,
|
||||
lastFetchedAt: now,
|
||||
});
|
||||
}
|
||||
let crewOrder = 100;
|
||||
for (const c of notableCrew) {
|
||||
const personId = personIds.get(c.id);
|
||||
if (!personId) continue;
|
||||
allCastRows.push({
|
||||
titleId,
|
||||
personId,
|
||||
character: null,
|
||||
department: c.department,
|
||||
job: c.job,
|
||||
displayOrder: crewOrder,
|
||||
episodeCount: null,
|
||||
lastFetchedAt: now,
|
||||
});
|
||||
crewOrder++;
|
||||
}
|
||||
if (allCastRows.length > 0) {
|
||||
db.insert(titleCast)
|
||||
.values(allCastRows)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
titleCast.titleId,
|
||||
titleCast.personId,
|
||||
titleCast.department,
|
||||
titleCast.character,
|
||||
],
|
||||
set: {
|
||||
job: sql`excluded.job`,
|
||||
displayOrder: sql`excluded.displayOrder`,
|
||||
episodeCount: sql`excluded.episodeCount`,
|
||||
lastFetchedAt: sql`excluded.lastFetchedAt`,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
} else {
|
||||
const credits = await getTvAggregateCredits(title.tmdbId);
|
||||
const castSlice = credits.cast.slice(0, 20);
|
||||
@@ -228,74 +214,60 @@ export async function refreshCredits(titleId: string) {
|
||||
];
|
||||
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++;
|
||||
}
|
||||
});
|
||||
// Collect all titleCast rows (cast + crew) and batch insert
|
||||
const now = new Date();
|
||||
const allCastRows: (typeof titleCast.$inferInsert)[] = [];
|
||||
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;
|
||||
allCastRows.push({
|
||||
titleId,
|
||||
personId,
|
||||
character,
|
||||
department: "Acting",
|
||||
job: null,
|
||||
displayOrder: i,
|
||||
episodeCount: c.total_episode_count,
|
||||
lastFetchedAt: now,
|
||||
});
|
||||
}
|
||||
let crewOrder = 100;
|
||||
for (const c of notableCrew) {
|
||||
const personId = personIds.get(c.person.id);
|
||||
if (!personId) continue;
|
||||
allCastRows.push({
|
||||
titleId,
|
||||
personId,
|
||||
character: null,
|
||||
department: c.person.department,
|
||||
job: c.job,
|
||||
displayOrder: crewOrder,
|
||||
episodeCount: c.episodeCount,
|
||||
lastFetchedAt: now,
|
||||
});
|
||||
crewOrder++;
|
||||
}
|
||||
if (allCastRows.length > 0) {
|
||||
db.insert(titleCast)
|
||||
.values(allCastRows)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
titleCast.titleId,
|
||||
titleCast.personId,
|
||||
titleCast.department,
|
||||
titleCast.character,
|
||||
],
|
||||
set: {
|
||||
job: sql`excluded.job`,
|
||||
displayOrder: sql`excluded.displayOrder`,
|
||||
episodeCount: sql`excluded.episodeCount`,
|
||||
lastFetchedAt: sql`excluded.lastFetchedAt`,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Credits refreshed for "${title.title}"`);
|
||||
|
||||
+60
-42
@@ -145,22 +145,13 @@ export async function cacheImagesForTitle(titleId: string) {
|
||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
if (!title) return;
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
// Collect all candidate images, then check cache in parallel
|
||||
const candidates: { imgPath: string; category: ImageCategory }[] = [];
|
||||
if (title.posterPath)
|
||||
candidates.push({ imgPath: title.posterPath, category: "posters" });
|
||||
if (title.backdropPath)
|
||||
candidates.push({ imgPath: title.backdropPath, category: "backdrops" });
|
||||
|
||||
if (
|
||||
title.posterPath &&
|
||||
!(await isImageCached("posters", path.basename(title.posterPath)))
|
||||
) {
|
||||
tasks.push(downloadAndCacheImage(title.posterPath, "posters"));
|
||||
}
|
||||
if (
|
||||
title.backdropPath &&
|
||||
!(await isImageCached("backdrops", path.basename(title.backdropPath)))
|
||||
) {
|
||||
tasks.push(downloadAndCacheImage(title.backdropPath, "backdrops"));
|
||||
}
|
||||
|
||||
// Season posters
|
||||
if (title.type === "tv") {
|
||||
const allSeasons = db
|
||||
.select()
|
||||
@@ -168,15 +159,22 @@ export async function cacheImagesForTitle(titleId: string) {
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
for (const s of allSeasons) {
|
||||
if (
|
||||
s.posterPath &&
|
||||
!(await isImageCached("posters", path.basename(s.posterPath)))
|
||||
) {
|
||||
tasks.push(downloadAndCacheImage(s.posterPath, "posters"));
|
||||
}
|
||||
if (s.posterPath)
|
||||
candidates.push({ imgPath: s.posterPath, category: "posters" });
|
||||
}
|
||||
}
|
||||
|
||||
// Parallel cache checks instead of sequential awaits
|
||||
const checks = await Promise.all(
|
||||
candidates.map(async (c) => ({
|
||||
...c,
|
||||
cached: await isImageCached(c.category, path.basename(c.imgPath)),
|
||||
})),
|
||||
);
|
||||
const tasks = checks
|
||||
.filter((c) => !c.cached)
|
||||
.map((c) => downloadAndCacheImage(c.imgPath, c.category));
|
||||
|
||||
if (tasks.length > 0) {
|
||||
log.debug(`Caching ${tasks.length} images for title ${titleId}`);
|
||||
}
|
||||
@@ -200,15 +198,20 @@ export async function cacheEpisodeStills(titleId: string) {
|
||||
.where(inArray(episodes.seasonId, seasonIds))
|
||||
.all();
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
for (const ep of allEps) {
|
||||
if (
|
||||
ep.stillPath &&
|
||||
!(await isImageCached("stills", path.basename(ep.stillPath)))
|
||||
) {
|
||||
tasks.push(downloadAndCacheImage(ep.stillPath, "stills"));
|
||||
}
|
||||
}
|
||||
const epsWithStills = allEps.filter(
|
||||
(ep): ep is typeof ep & { stillPath: string } => ep.stillPath != null,
|
||||
);
|
||||
// Parallel cache checks instead of sequential awaits
|
||||
const checks = await Promise.all(
|
||||
epsWithStills.map(async (ep) => ({
|
||||
stillPath: ep.stillPath,
|
||||
cached: await isImageCached("stills", path.basename(ep.stillPath)),
|
||||
})),
|
||||
);
|
||||
const tasks = checks
|
||||
.filter((c) => !c.cached)
|
||||
.map((c) => downloadAndCacheImage(c.stillPath, "stills"));
|
||||
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
|
||||
@@ -219,17 +222,24 @@ export async function cacheProviderLogos(titleId: string) {
|
||||
.where(eq(availabilityOffers.titleId, titleId))
|
||||
.all();
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
// Deduplicate and parallel cache checks
|
||||
const uniqueLogos = new Map<string, string>();
|
||||
for (const offer of offers) {
|
||||
if (offer.logoPath) {
|
||||
const basename = path.basename(offer.logoPath);
|
||||
if (!seen.has(basename) && !(await isImageCached("logos", basename))) {
|
||||
seen.add(basename);
|
||||
tasks.push(downloadAndCacheImage(offer.logoPath, "logos"));
|
||||
}
|
||||
if (!uniqueLogos.has(basename)) uniqueLogos.set(basename, offer.logoPath);
|
||||
}
|
||||
}
|
||||
const checks = await Promise.all(
|
||||
[...uniqueLogos.entries()].map(async ([basename, logoPath]) => ({
|
||||
logoPath,
|
||||
cached: await isImageCached("logos", basename),
|
||||
})),
|
||||
);
|
||||
const tasks = checks
|
||||
.filter((c) => !c.cached)
|
||||
.map((c) => downloadAndCacheImage(c.logoPath, "logos"));
|
||||
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
|
||||
@@ -241,17 +251,25 @@ export async function cacheProfilePhotos(titleId: string) {
|
||||
.where(eq(titleCast.titleId, titleId))
|
||||
.all();
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
const seen = new Set<string>();
|
||||
// Deduplicate and parallel cache checks
|
||||
const uniqueProfiles = new Map<string, string>();
|
||||
for (const row of castRows) {
|
||||
if (row.profilePath) {
|
||||
const basename = path.basename(row.profilePath);
|
||||
if (!seen.has(basename) && !(await isImageCached("profiles", basename))) {
|
||||
seen.add(basename);
|
||||
tasks.push(downloadAndCacheImage(row.profilePath, "profiles"));
|
||||
}
|
||||
if (!uniqueProfiles.has(basename))
|
||||
uniqueProfiles.set(basename, row.profilePath);
|
||||
}
|
||||
}
|
||||
const checks = await Promise.all(
|
||||
[...uniqueProfiles.entries()].map(async ([basename, profilePath]) => ({
|
||||
profilePath,
|
||||
cached: await isImageCached("profiles", basename),
|
||||
})),
|
||||
);
|
||||
const tasks = checks
|
||||
.filter((c) => !c.cached)
|
||||
.map((c) => downloadAndCacheImage(c.profilePath, "profiles"));
|
||||
|
||||
if (tasks.length > 0) {
|
||||
log.debug(`Caching ${tasks.length} profile photos for title ${titleId}`);
|
||||
}
|
||||
|
||||
+23
-18
@@ -88,27 +88,32 @@ export async function getSonarrList(
|
||||
)
|
||||
.all();
|
||||
|
||||
const result: { TvdbId: number; Title: string }[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
let { tvdbId } = row;
|
||||
|
||||
if (tvdbId == null) {
|
||||
try {
|
||||
const externalIds = await getTvExternalIds(row.tmdbId);
|
||||
tvdbId = externalIds.tvdb_id;
|
||||
// Resolve missing TVDB IDs in parallel instead of sequentially
|
||||
const needsResolution = rows.filter((r) => r.tvdbId == null);
|
||||
if (needsResolution.length > 0) {
|
||||
const resolved = await Promise.all(
|
||||
needsResolution.map(async (row) => {
|
||||
try {
|
||||
const externalIds = await getTvExternalIds(row.tmdbId);
|
||||
return { row, tvdbId: externalIds.tvdb_id };
|
||||
} catch (err) {
|
||||
log.warn(`Failed to resolve TVDB ID for TMDB ${row.tmdbId}:`, err);
|
||||
return { row, tvdbId: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Batch update resolved IDs in a single transaction
|
||||
db.transaction((tx) => {
|
||||
for (const { row, tvdbId } of resolved) {
|
||||
if (tvdbId != null) {
|
||||
db.update(titles).set({ tvdbId }).where(eq(titles.id, row.id)).run();
|
||||
row.tvdbId = tvdbId;
|
||||
tx.update(titles).set({ tvdbId }).where(eq(titles.id, row.id)).run();
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Failed to resolve TVDB ID for TMDB ${row.tmdbId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (tvdbId != null) {
|
||||
result.push({ TvdbId: tvdbId, Title: row.title });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
return rows
|
||||
.filter((r): r is typeof r & { tvdbId: number } => r.tvdbId != null)
|
||||
.map((r) => ({ TvdbId: r.tvdbId, Title: r.title }));
|
||||
}
|
||||
|
||||
+68
-52
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import {
|
||||
availabilityOffers,
|
||||
@@ -48,7 +48,7 @@ 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) {
|
||||
function upsertTitle(values: typeof titles.$inferInsert, tmdbId: number) {
|
||||
try {
|
||||
return db.insert(titles).values(values).returning().get();
|
||||
} catch (err: unknown) {
|
||||
@@ -66,19 +66,21 @@ function insertTitleOrGet(values: typeof titles.$inferInsert, tmdbId: number) {
|
||||
|
||||
function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
|
||||
if (tmdbGenres.length === 0) return;
|
||||
for (const g of tmdbGenres) {
|
||||
db.insert(genres)
|
||||
.values({ id: g.id, name: g.name })
|
||||
.onConflictDoUpdate({ target: genres.id, set: { name: g.name } })
|
||||
.run();
|
||||
}
|
||||
db.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
|
||||
for (const g of tmdbGenres) {
|
||||
db.insert(titleGenres)
|
||||
.values({ titleId, genreId: g.id })
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
db.transaction((tx) => {
|
||||
for (const g of tmdbGenres) {
|
||||
tx.insert(genres)
|
||||
.values({ id: g.id, name: g.name })
|
||||
.onConflictDoUpdate({ target: genres.id, set: { name: g.name } })
|
||||
.run();
|
||||
}
|
||||
tx.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
|
||||
for (const g of tmdbGenres) {
|
||||
tx.insert(titleGenres)
|
||||
.values({ titleId, genreId: g.id })
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -99,12 +101,12 @@ export function extractTvContentRating(show: TmdbTvDetails): string | null {
|
||||
return us?.rating || null;
|
||||
}
|
||||
|
||||
type ImportResult = ReturnType<typeof _importTitle>;
|
||||
type ImportResult = ReturnType<typeof _getOrFetchTitleByTmdbId>;
|
||||
|
||||
/** In-flight import promises keyed by tmdbId — coalesces concurrent calls */
|
||||
const inflightImports = new Map<number, ImportResult>();
|
||||
|
||||
export function importTitle(
|
||||
export function getOrFetchTitleByTmdbId(
|
||||
tmdbId: number,
|
||||
type: "movie" | "tv",
|
||||
): ImportResult {
|
||||
@@ -114,14 +116,14 @@ export function importTitle(
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const promise = _importTitle(tmdbId, type).finally(() => {
|
||||
const promise = _getOrFetchTitleByTmdbId(tmdbId, type).finally(() => {
|
||||
inflightImports.delete(tmdbId);
|
||||
}) as ImportResult;
|
||||
inflightImports.set(tmdbId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function _importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
log.debug(`Importing ${type} TMDB ${tmdbId}`);
|
||||
|
||||
const existing = db
|
||||
@@ -191,7 +193,7 @@ async function _importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
|
||||
if (type === "movie") {
|
||||
const movie = await getMovieDetails(tmdbId);
|
||||
const row = insertTitleOrGet(
|
||||
const row = upsertTitle(
|
||||
{
|
||||
tmdbId: movie.id,
|
||||
type: "movie",
|
||||
@@ -237,7 +239,7 @@ async function _importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
}
|
||||
|
||||
const show = await getTvDetails(tmdbId);
|
||||
const row = insertTitleOrGet(
|
||||
const row = upsertTitle(
|
||||
{
|
||||
tmdbId: show.id,
|
||||
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||
@@ -401,28 +403,33 @@ export async function refreshTvChildren(
|
||||
.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();
|
||||
// Batch all episode upserts in a single transaction per season
|
||||
if (seasonData.episodes.length > 0) {
|
||||
db.transaction((tx) => {
|
||||
for (const ep of seasonData.episodes) {
|
||||
tx.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
|
||||
@@ -534,25 +541,34 @@ export async function refreshRecommendations(titleId: string) {
|
||||
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({
|
||||
// Batch upsert all recommendation rows (N inserts → 1)
|
||||
const recRows = allItems
|
||||
.map((item) => {
|
||||
const recTitleId = titleIdMap.get(item.result.id);
|
||||
if (!recTitleId) return null;
|
||||
return {
|
||||
titleId,
|
||||
recommendedTitleId: recTitleId,
|
||||
source: item.source,
|
||||
rank: item.rank,
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
};
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
|
||||
if (recRows.length > 0) {
|
||||
tx.insert(titleRecommendations)
|
||||
.values(recRows)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
titleRecommendations.titleId,
|
||||
titleRecommendations.recommendedTitleId,
|
||||
titleRecommendations.source,
|
||||
],
|
||||
set: { rank: item.rank, lastFetchedAt: now },
|
||||
set: {
|
||||
rank: sql`excluded.rank`,
|
||||
lastFetchedAt: sql`excluded.lastFetchedAt`,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
@@ -742,7 +758,7 @@ function readAvailability(
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getTitleWithChildren(id: string): Promise<{
|
||||
export async function getOrFetchTitle(id: string): Promise<{
|
||||
title: ResolvedTitle;
|
||||
seasons: Season[];
|
||||
needsHydration: boolean;
|
||||
|
||||
+30
-56
@@ -77,16 +77,15 @@ export function logEpisodeWatch(
|
||||
.values({ userId, episodeId, watchedAt: now, source })
|
||||
.run();
|
||||
|
||||
// Find the title for this episode
|
||||
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get();
|
||||
if (!ep) return;
|
||||
const season = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.id, ep.seasonId))
|
||||
// Find the title for this episode (single JOIN instead of 2 queries)
|
||||
const row = db
|
||||
.select({ titleId: seasons.titleId })
|
||||
.from(episodes)
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(eq(episodes.id, episodeId))
|
||||
.get();
|
||||
if (!season) return;
|
||||
const { titleId } = season;
|
||||
if (!row) return;
|
||||
const { titleId } = row;
|
||||
|
||||
// Auto-set status to in_progress if not set
|
||||
const existing = db
|
||||
@@ -231,22 +230,14 @@ export function markAllEpisodesWatched(
|
||||
if (!title || title.type !== "tv") return;
|
||||
|
||||
const now = new Date();
|
||||
const allSeasons = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
|
||||
const allEps = db
|
||||
.select({ id: episodes.id })
|
||||
.from(episodes)
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
|
||||
const seasonIds = allSeasons.map((s) => s.id);
|
||||
const allEps =
|
||||
seasonIds.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(inArray(episodes.seasonId, seasonIds))
|
||||
.all()
|
||||
: [];
|
||||
|
||||
const epIds = allEps.map((ep) => ep.id);
|
||||
const existingWatches =
|
||||
epIds.length > 0
|
||||
@@ -279,19 +270,12 @@ export function markAllEpisodesWatched(
|
||||
}
|
||||
|
||||
function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
const allSeasons = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
|
||||
if (allSeasons.length === 0) return;
|
||||
|
||||
const seasonIds = allSeasons.map((s) => s.id);
|
||||
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
|
||||
const allEps = db
|
||||
.select()
|
||||
.select({ id: episodes.id })
|
||||
.from(episodes)
|
||||
.where(inArray(episodes.seasonId, seasonIds))
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
|
||||
const totalEpisodes = allEps.length;
|
||||
@@ -327,14 +311,13 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
.run();
|
||||
|
||||
// Find parent title and downgrade from completed to in_progress
|
||||
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get();
|
||||
if (!ep) return;
|
||||
const season = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.id, ep.seasonId))
|
||||
const row = db
|
||||
.select({ titleId: seasons.titleId })
|
||||
.from(episodes)
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(eq(episodes.id, episodeId))
|
||||
.get();
|
||||
if (!season) return;
|
||||
if (!row) return;
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
@@ -342,13 +325,13 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.titleId, season.titleId),
|
||||
eq(userTitleStatus.titleId, row.titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existing?.status === "completed") {
|
||||
setTitleStatus(userId, season.titleId, "in_progress");
|
||||
setTitleStatus(userId, row.titleId, "in_progress");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,23 +528,14 @@ export function getUserTitleInfo(userId: string, titleId: string) {
|
||||
)
|
||||
.get();
|
||||
|
||||
// Batch fetch all episode IDs for this title
|
||||
const titleSeasons = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
|
||||
const allEps = db
|
||||
.select({ id: episodes.id })
|
||||
.from(episodes)
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
|
||||
const seasonIds = titleSeasons.map((s) => s.id);
|
||||
const allEps =
|
||||
seasonIds.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(inArray(episodes.seasonId, seasonIds))
|
||||
.all()
|
||||
: [];
|
||||
|
||||
const epIds = allEps.map((ep) => ep.id);
|
||||
|
||||
// Batch fetch all watches for these episodes
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { findByExternalId, searchTv } from "@/lib/tmdb/client";
|
||||
import { importTitle } from "./metadata";
|
||||
import { getOrFetchTitleByTmdbId } from "./metadata";
|
||||
import { logEpisodeWatch, logMovieWatch } from "./tracking";
|
||||
|
||||
const log = createLogger("webhooks");
|
||||
@@ -330,7 +330,7 @@ export async function processWebhook(
|
||||
return { status: "error", message: "Could not resolve TMDB ID" };
|
||||
}
|
||||
|
||||
const title = await importTitle(tmdbId, "movie");
|
||||
const title = await getOrFetchTitleByTmdbId(tmdbId, "movie");
|
||||
if (!title) {
|
||||
logEvent(connectionId, event, "error", "Failed to import movie");
|
||||
return { status: "error", message: "Failed to import movie" };
|
||||
@@ -361,7 +361,7 @@ export async function processWebhook(
|
||||
return { status: "error", message: "Could not resolve episode" };
|
||||
}
|
||||
|
||||
const title = await importTitle(resolved.showTmdbId, "tv");
|
||||
const title = await getOrFetchTitleByTmdbId(resolved.showTmdbId, "tv");
|
||||
if (!title) {
|
||||
logEvent(connectionId, event, "error", "Failed to import TV show");
|
||||
return { status: "error", message: "Failed to import TV show" };
|
||||
|
||||
Reference in New Issue
Block a user