refactor: return pre-resolved internal IDs from all listing endpoints and remove client-side resolve mutations

All explore, discover, search, recommendation, and person-credit listing procedures now include the internal database `id` on every item so clients can navigate and act without a separate resolve round-trip.

- Remove `titles.resolve` and `people.resolve` mutation calls from the native search screen, hero banners, poster rows, and cast cards; replace with direct `Link` navigation using the pre-returned `id`.
- Change `titles.quickAdd` to accept `{ id }` instead of `{ tmdbId, type }` and update every call site on native and web.
- Key all user-status and episode-progress lookups by `id` instead of `tmdbId-type` composite strings across `PosterCard`, `HorizontalPosterRow`, `FilterableTitleRow`, and `usePosterActions`; drop the `tmdbId` prop from `PosterCard` entirely.
- Remove the `titles.hydrateSeasons` auto-trigger from the title detail screen; season hydration now happens server-side on resolve.
- Delete the `browse-thumbhashes` and `browse-title-ids` server procedures and remove them from the router.
- Extend `@sofa/api` schemas with an `id` field on all listing-item types; update `packages/core` services and add a DB migration accordingly.
This commit is contained in:
jake committed 2026-03-16 16:35:50 -04:00
1 parent ccc7922737
commit 25736f684a
48 files changed
+6965 -1777

No files matched your search

+5 -53
View File
@@ -14,8 +14,6 @@ import {
DiscoverOutput,
FilenameParam,
GenresOutput,
HydrateSeasonsInput,
HydrateSeasonsOutput,
IdParam,
IntegrationOutput,
IntegrationsListOutput,
@@ -24,7 +22,6 @@ import {
PageParam,
PaginatedInput,
PersonDetailOutput,
PersonResolveOutput,
PopularOutput,
ProviderParam,
PublicInfoOutput,
@@ -40,9 +37,6 @@ import {
TelemetryOutput,
TitleDetailOutput,
TitleRecommendationsOutput,
TitleResolveOutput,
TmdbIdParam,
TmdbIdTypeParam,
ToggleRegistrationInput,
ToggleTelemetryInput,
ToggleUpdateCheckInput,
@@ -80,21 +74,6 @@ export const contract = {
.errors({
NOT_FOUND: { message: "Title not found" },
}),
resolve: oc
.route({
method: "POST",
path: "/titles/resolve",
tags: ["Titles"],
summary: "Resolve TMDB ID to local title",
description:
"Look up or import a title by its TMDB ID and media type. Returns the internal ID for use with other endpoints.",
successDescription: "Internal title ID",
})
.input(TmdbIdTypeParam)
.output(TitleResolveOutput)
.errors({
NOT_FOUND: { message: "Title not found" },
}),
updateStatus: oc
.route({
method: "PUT",
@@ -164,33 +143,21 @@ export const contract = {
})
.input(IdParam)
.output(TitleRecommendationsOutput),
hydrateSeasons: oc
.route({
method: "POST",
path: "/titles/{id}/hydrate-seasons",
tags: ["Titles"],
summary: "Hydrate TV seasons",
description:
"Fetch full season and episode data from TMDB for a TV show. Required before tracking individual episodes.",
successDescription: "Hydrated seasons with episodes",
})
.input(HydrateSeasonsInput)
.output(HydrateSeasonsOutput),
quickAdd: oc
.route({
method: "POST",
path: "/titles/quick-add",
path: "/titles/{id}/quick-add",
tags: ["Titles"],
summary: "Quick add title to library",
description:
"Import a title by TMDB ID and add it to the user's watchlist in one step. If the title already exists in the user's library, returns alreadyAdded: true.",
"Add a title to the user's watchlist and trigger a full TMDB import if needed. If the title already exists in the user's library, returns alreadyAdded: true.",
successDescription:
"Title ID and whether it was already in the library",
})
.input(TmdbIdTypeParam)
.input(IdParam)
.output(QuickAddOutput)
.errors({
INTERNAL_SERVER_ERROR: { message: "Failed to import title from TMDB" },
NOT_FOUND: { message: "Title not found" },
}),
},
episodes: {
@@ -267,21 +234,6 @@ export const contract = {
.errors({
NOT_FOUND: { message: "Person not found" },
}),
resolve: oc
.route({
method: "POST",
path: "/people/resolve",
tags: ["People"],
summary: "Resolve TMDB ID to local person",
description:
"Look up or import a person by their TMDB ID. Returns the internal ID for use with other endpoints.",
successDescription: "Internal person ID",
})
.input(TmdbIdParam)
.output(PersonResolveOutput)
.errors({
NOT_FOUND: { message: "Person not found" },
}),
},
dashboard: {
stats: oc
@@ -461,7 +413,7 @@ export const contract = {
tags: ["System"],
summary: "Get system health report",
description:
"Comprehensive health check covering database, TMDB connectivity, cron jobs, image cache, backups, and environment. Does not require authentication.",
"Comprehensive health check covering database, TMDB connectivity, cron jobs, image cache, backups, and environment. Admin only.",
successDescription: "Full system health report",
})
.output(SystemHealthOutput),
+19 -55
View File
@@ -10,9 +10,6 @@ export const ProviderParam = z.object({
.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"])
.describe("Media server provider type"),
});
export const TmdbIdParam = z.object({
tmdbId: z.number().int().describe("The Movie Database (TMDB) numeric ID"),
});
export const FilenameParam = z.object({
filename: z.string().min(1).describe("Backup filename"),
});
@@ -24,13 +21,6 @@ export const TrendingTypeParam = z.object({
.enum(["all", "movie", "tv"])
.describe("Trending category: all, movie, or tv"),
});
export const TmdbIdTypeParam = z
.object({
tmdbId: z.number().int().describe("TMDB numeric ID"),
type: z.enum(["movie", "tv"]).describe("Media type"),
})
.meta({ description: "TMDB ID and media type pair for resolving titles" });
// ─── Pagination ──────────────────────────────────────────────
/** Page param for TMDB-backed endpoints (fixed ~20 items/page from TMDB) */
@@ -103,13 +93,6 @@ export const BatchWatchInput = z
})
.meta({ description: "Batch of episode IDs to mark as watched" });
export const HydrateSeasonsInput = z
.object({
id: z.string().min(1).describe("Internal title ID"),
tmdbId: z.number().int().describe("TMDB ID for fetching season data"),
})
.meta({ description: "Title identifiers for fetching season/episode data" });
// ─── Search / Discover inputs ──────────────────────────────────
export const SearchInput = z
@@ -394,6 +377,16 @@ export const ResolvedTitleSchema = z
.string()
.nullable()
.describe("Content rating (e.g. PG-13, TV-MA)"),
imdbId: z.string().nullable().describe("IMDb title ID (e.g. tt0137523)"),
tvdbId: z.number().nullable().describe("TVDB ID (TV shows only)"),
originalLanguage: z
.string()
.nullable()
.describe("Original language ISO 639-1 code (e.g. en)"),
runtimeMinutes: z
.number()
.nullable()
.describe("Runtime in minutes (movies only)"),
colorPalette: ColorPaletteSchema.nullable(),
trailerVideoKey: z
.string()
@@ -451,10 +444,7 @@ export const PersonCreditSchema = z
/** Reusable TMDB browse result (trending / popular / discover items) */
export const TmdbBrowseItem = z
.object({
id: z
.string()
.optional()
.describe("Internal title ID when the title already exists locally"),
id: z.string().describe("Internal title ID"),
tmdbId: z.number().describe("TMDB numeric ID"),
type: mediaType,
title: z.string().describe("Display title"),
@@ -514,14 +504,7 @@ const BrowseOutput = z
export const TitleDetailOutput = z
.object({
title: ResolvedTitleSchema,
seasons: z
.array(SeasonSchema)
.describe("TV seasons (empty for movies or unhydrated shows)"),
needsHydration: z
.boolean()
.describe(
"Whether season/episode data needs to be fetched from TMDB before tracking",
),
seasons: z.array(SeasonSchema).describe("TV seasons (empty for movies)"),
availability: z
.array(AvailabilityOfferSchema)
.describe("Streaming availability offers"),
@@ -532,12 +515,6 @@ export const TitleDetailOutput = z
"Full title details with seasons, cast, and streaming availability",
});
export const TitleResolveOutput = z
.object({
id: z.string().describe("Internal title ID"),
})
.meta({ description: "Resolved internal title ID" });
export const UserInfoOutput = z
.object({
status: z
@@ -581,12 +558,6 @@ export const PersonDetailOutput = z
"Person profile with paginated filmography and user's statuses for their titles",
});
export const PersonResolveOutput = z
.object({
id: z.string().describe("Internal person ID"),
})
.meta({ description: "Resolved internal person ID" });
// ─── Dashboard outputs ─────────────────────────────────────────
export const DashboardStatsOutput = z
@@ -694,10 +665,7 @@ export const TrendingOutput = z
items: z.array(TmdbBrowseItem).describe("Trending titles"),
hero: z
.object({
id: z
.string()
.optional()
.describe("Internal title ID when the title already exists locally"),
id: z.string().describe("Internal title ID"),
tmdbId: z.number().describe("TMDB numeric ID"),
type: mediaType,
title: z.string().describe("Display title"),
@@ -735,6 +703,12 @@ export const SearchOutput = z
results: z.array(
z
.object({
id: z
.string()
.optional()
.describe(
"Internal title ID (present for movie/tv results, absent for people)",
),
tmdbId: z.number().describe("TMDB numeric ID"),
type: z.enum(["movie", "tv", "person"]).describe("Result type"),
title: z.string().describe("Title or person name"),
@@ -1121,16 +1095,6 @@ export const AuthConfigOutput = z
description: "Authentication provider configuration",
});
// ─── Title hydrate seasons output ─────────────────────────────
export const HydrateSeasonsOutput = z
.object({
seasons: z.array(SeasonSchema).describe("Hydrated seasons with episodes"),
})
.meta({
description: "Freshly fetched season and episode data from TMDB",
});
// ═══════════════════════════════════════════════════════════════
// Inferred types — use these instead of hand-written interfaces
// ═══════════════════════════════════════════════════════════════
+362 -179
View File
@@ -152,25 +152,52 @@ export function extractTvContentRating(show: TmdbTvDetails): string | null {
return us?.rating || null;
}
/** Fire-and-forget enrichment tasks (availability, recommendations, art, credits, trailer) */
function fireAndForgetEnrichment(
titleId: string,
posterPath: string | null | undefined,
backdropPath: string | null | undefined,
type: "movie" | "tv",
) {
refreshAvailability(titleId).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(titleId).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
syncTitleArt(titleId, posterPath, backdropPath, type).catch((err) =>
log.debug("Cache/thumbhash failed:", err),
);
refreshCredits(titleId).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(titleId).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
}
type ImportResult = ReturnType<typeof _getOrFetchTitleByTmdbId>;
/** In-flight import promises keyed by tmdbId — coalesces concurrent calls */
const inflightImports = new Map<number, ImportResult>();
/** In-flight import promises keyed by `${tmdbId}-${type}` — coalesces concurrent calls */
const inflightImports = new Map<string, ImportResult>();
export function getOrFetchTitleByTmdbId(
tmdbId: number,
type: "movie" | "tv",
): ImportResult {
const inflight = inflightImports.get(tmdbId);
const key = `${tmdbId}-${type}`;
const inflight = inflightImports.get(key);
if (inflight) {
log.debug(`Import already in-flight for TMDB ${tmdbId}, coalescing`);
log.debug(
`Import already in-flight for ${type} TMDB ${tmdbId}, coalescing`,
);
return inflight;
}
const promise = _getOrFetchTitleByTmdbId(tmdbId, type).finally(() => {
inflightImports.delete(tmdbId);
inflightImports.delete(key);
}) as ImportResult;
inflightImports.set(tmdbId, promise);
inflightImports.set(key, promise);
return promise;
}
@@ -180,12 +207,65 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
const existing = db
.select()
.from(titles)
.where(eq(titles.tmdbId, tmdbId))
.where(and(eq(titles.tmdbId, tmdbId), eq(titles.type, type)))
.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).
// Shell title — upgrade to full import
if (!existing.lastFetchedAt) {
if (type === "movie") {
const movie = await getMovieDetails(tmdbId);
updateTitleWithArtInvalidation(existing, {
title: movie.title ?? existing.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,
contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(existing.id, movie.genres ?? []);
fireAndForgetEnrichment(
existing.id,
movie.poster_path,
movie.backdrop_path,
"movie",
);
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
}
// TV shell — fetch details + children
const show = await getTvDetails(tmdbId);
updateTitleWithArtInvalidation(existing, {
overview: show.overview,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
status: show.status,
contentRating: extractTvContentRating(show),
tvdbId: show.external_ids?.tvdb_id ?? null,
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
fireAndForgetEnrichment(
existing.id,
show.poster_path,
show.backdrop_path,
"tv",
);
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
}
// For fully-fetched TV shows, check if seasons are missing (e.g. prior failure)
if (existing.type === "tv") {
const hasSeason = db
.select({ id: seasons.id })
@@ -195,40 +275,18 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
.get();
if (!hasSeason) {
const show = await getTvDetails(tmdbId);
if (!existing.lastFetchedAt) {
updateTitleWithArtInvalidation(existing, {
overview: show.overview,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
status: show.status,
contentRating: extractTvContentRating(show),
tvdbId: show.external_ids?.tvdb_id ?? null,
lastFetchedAt: new Date(),
});
}
upsertGenres(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
refreshAvailability(existing.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(existing.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
syncTitleArt(
fireAndForgetEnrichment(
existing.id,
show.poster_path,
show.backdrop_path,
"tv",
).catch((err) => log.debug("Cache/thumbhash failed:", err));
refreshCredits(existing.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(existing.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
}
}
return existing;
}
@@ -251,26 +309,20 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: now,
},
tmdbId,
);
if (!row) return undefined;
upsertGenres(row.id, movie.genres ?? []);
refreshAvailability(row.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
syncTitleArt(row.id, movie.poster_path, movie.backdrop_path, "movie").catch(
(err) => log.debug("Cache/thumbhash failed:", err),
);
refreshCredits(row.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(row.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
fireAndForgetEnrichment(
row.id,
movie.poster_path,
movie.backdrop_path,
"movie",
);
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
return row;
@@ -293,6 +345,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
voteCount: show.vote_count,
status: show.status,
contentRating: extractTvContentRating(show),
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: now,
},
tmdbId,
@@ -301,21 +355,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
upsertGenres(row.id, show.genres ?? []);
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
refreshAvailability(row.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
syncTitleArt(row.id, show.poster_path, show.backdrop_path, "tv").catch(
(err) => log.debug("Cache/thumbhash failed:", err),
);
refreshCredits(row.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
);
refreshTrailer(row.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
fireAndForgetEnrichment(row.id, show.poster_path, show.backdrop_path, "tv");
log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`);
return row;
}
@@ -340,6 +380,9 @@ export async function refreshTitle(titleId: string) {
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: now,
});
upsertGenres(titleId, movie.genres ?? []);
@@ -358,6 +401,8 @@ export async function refreshTitle(titleId: string) {
status: show.status,
contentRating: extractTvContentRating(show),
tvdbId: show.external_ids?.tvdb_id ?? null,
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: now,
});
upsertGenres(titleId, show.genres ?? []);
@@ -387,125 +432,135 @@ export async function refreshTvChildren(
tmdbId: number,
numberOfSeasons: number,
) {
// Fetch all seasons from TMDB concurrently (~40 req/s rate limit)
const seasonNumbers = Array.from(
{ length: numberOfSeasons },
(_, i) => i + 1,
);
const fetched = await Promise.allSettled(
seasonNumbers.map((sn) => getTvSeasonDetails(tmdbId, sn)),
);
const now = new Date();
for (let sn = 1; sn <= numberOfSeasons; sn++) {
// Rate-limit: 250ms between TMDB calls
if (sn > 1) await delay(250);
for (let i = 0; i < seasonNumbers.length; i++) {
const result = fetched[i];
const sn = seasonNumbers[i];
if (result.status === "rejected") {
log.error(
`Failed to fetch season ${sn} for TMDB ${tmdbId}:`,
result.reason,
);
continue;
}
try {
const seasonData = await getTvSeasonDetails(tmdbId, sn);
const existingSeason = db
.select({
id: seasons.id,
posterPath: seasons.posterPath,
})
.from(seasons)
.where(and(eq(seasons.titleId, titleId), eq(seasons.seasonNumber, sn)))
.get();
const seasonData = result.value;
const seasonRow = db
.insert(seasons)
.values({
titleId,
seasonNumber: seasonData.season_number,
const existingSeason = db
.select({
id: seasons.id,
posterPath: seasons.posterPath,
})
.from(seasons)
.where(and(eq(seasons.titleId, titleId), eq(seasons.seasonNumber, sn)))
.get();
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,
})
.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();
},
})
.returning()
.get();
// Snapshot existing episode still paths before upsert so we can detect changes
const oldEpStills = new Map(
db
.select({
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
})
.from(episodes)
.where(eq(episodes.seasonId, existingSeason?.id ?? seasonRow.id))
.all()
.map((e) => [e.episodeNumber, e.stillPath] as const),
);
// Snapshot existing episode still paths before upsert so we can detect changes
const oldEpStills = new Map(
db
.select({
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
})
.from(episodes)
.where(eq(episodes.seasonId, existingSeason?.id ?? seasonRow.id))
.all()
.map((e) => [e.episodeNumber, e.stillPath] as const),
);
// Batch all episode upserts in a single transaction per season
const eps = seasonData.episodes ?? [];
if (eps.length > 0) {
db.transaction((tx) => {
for (const ep of eps) {
tx.insert(episodes)
.values({
seasonId: seasonRow.id,
episodeNumber: ep.episode_number,
// Batch all episode upserts in a single transaction per season
const eps = seasonData.episodes ?? [];
if (eps.length > 0) {
db.transaction((tx) => {
for (const ep of eps) {
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,
})
.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();
}
});
}
// Clear stale hashes when image paths change during the upsert.
// Full hash (re)generation is handled by syncTitleArt() after
// cache warming, so we only need to null out stale values here.
if (
(existingSeason?.posterPath ?? null) !==
(seasonData.poster_path ?? null)
) {
db.update(seasons)
.set({ posterThumbHash: null })
.where(eq(seasons.id, seasonRow.id))
.run();
}
const seasonEps = db
.select({
id: episodes.id,
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
stillThumbHash: episodes.stillThumbHash,
})
.from(episodes)
.where(eq(episodes.seasonId, seasonRow.id))
.all();
for (const ep of seasonEps) {
const oldStill = oldEpStills.get(ep.episodeNumber);
if (oldStill !== ep.stillPath && ep.stillThumbHash) {
db.update(episodes)
.set({ stillThumbHash: null })
.where(eq(episodes.id, ep.id))
},
})
.run();
}
});
}
// Clear stale hashes when image paths change during the upsert.
// Full hash (re)generation is handled by syncTitleArt() after
// cache warming, so we only need to null out stale values here.
if (
(existingSeason?.posterPath ?? null) !== (seasonData.poster_path ?? null)
) {
db.update(seasons)
.set({ posterThumbHash: null })
.where(eq(seasons.id, seasonRow.id))
.run();
}
const seasonEps = db
.select({
id: episodes.id,
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
stillThumbHash: episodes.stillThumbHash,
})
.from(episodes)
.where(eq(episodes.seasonId, seasonRow.id))
.all();
for (const ep of seasonEps) {
const oldStill = oldEpStills.get(ep.episodeNumber);
if (oldStill !== ep.stillPath && ep.stillThumbHash) {
db.update(episodes)
.set({ stillThumbHash: null })
.where(eq(episodes.id, ep.id))
.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);
}
}
}
@@ -797,13 +852,12 @@ function fetchSeasonsFromDb(titleId: string): Season[] {
* 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[]> {
export async function ensureTvHydrated(titleId: string): Promise<Season[]> {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title || title.type !== "tv") return [];
const { tmdbId } = title;
// Shell title: fetch details + children
if (!title.lastFetchedAt) {
try {
@@ -814,6 +868,8 @@ export async function ensureTvHydrated(
backdropPath: show.backdrop_path,
status: show.status,
contentRating: extractTvContentRating(show),
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(titleId, show.genres ?? []);
@@ -942,20 +998,12 @@ function readAvailability(
export async function getOrFetchTitle(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 TV titles, check if seasons need hydration (reuse result below)
const existingSeasons =
title.type === "tv" && title.lastFetchedAt ? fetchSeasonsFromDb(id) : null;
const needsTvHydration =
title.type === "tv" &&
(!title.lastFetchedAt || existingSeasons?.length === 0);
// If this is a shell movie title, fetch full details now (movies are fast)
if (title.type === "movie" && !title.lastFetchedAt) {
try {
@@ -972,6 +1020,9 @@ export async function getOrFetchTitle(id: string): Promise<{
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(id, movie.genres ?? []);
@@ -981,7 +1032,14 @@ export async function getOrFetchTitle(id: string): Promise<{
}
}
const titleSeasons = needsTvHydration ? [] : (existingSeasons ?? []);
// For TV titles, hydrate seasons inline if needed
let titleSeasons: Season[] = [];
if (title.type === "tv") {
titleSeasons = title.lastFetchedAt ? fetchSeasonsFromDb(id) : [];
if (titleSeasons.length === 0) {
titleSeasons = await ensureTvHydrated(id);
}
}
// Read enrichment data, then backfill anything missing
let availability = readAvailability(title.id, title.title);
@@ -1028,6 +1086,10 @@ export async function getOrFetchTitle(id: string): Promise<{
voteCount: title.voteCount,
status: title.status,
contentRating: title.contentRating,
imdbId: title.imdbId,
tvdbId: title.tvdbId,
originalLanguage: title.originalLanguage,
runtimeMinutes: title.runtimeMinutes,
colorPalette: palette,
trailerVideoKey: title.trailerVideoKey,
genres: titleGenreRows.map((r) => r.name),
@@ -1036,7 +1098,6 @@ export async function getOrFetchTitle(id: string): Promise<{
return {
title: resolvedTitle,
seasons: titleSeasons,
needsHydration: needsTvHydration,
availability,
cast,
};
@@ -1175,6 +1236,128 @@ async function syncTitleArt(
}
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
// ─── Browse batch upsert ─────────────────────────────────────
interface BrowseTitleInput {
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
backdropPath?: string | null;
releaseDate?: string | null;
firstAirDate?: string | null;
overview?: string | null;
popularity?: number | null;
voteAverage?: number | null;
voteCount?: number | null;
}
function browseTitleKey(tmdbId: number, type: string): string {
return `${tmdbId}-${type}`;
}
/**
* Ensure every browse/search result has a local title row.
* Inserts shell titles (`lastFetchedAt = null`) for new (tmdbId, type) pairs.
* Returns a map keyed by `${tmdbId}-${type}` → { id, posterThumbHash }.
*/
export function ensureBrowseTitlesExist(
items: BrowseTitleInput[],
): Map<string, { id: string; posterThumbHash: string | null }> {
if (items.length === 0) return new Map();
// Deduplicate by (tmdbId, type)
const unique = new Map<string, BrowseTitleInput>();
for (const item of items) {
const key = browseTitleKey(item.tmdbId, item.type);
if (!unique.has(key)) unique.set(key, item);
}
const tmdbIds = [...new Set(items.map((i) => i.tmdbId))];
// Batch-fetch existing titles (1 query)
const existing = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all();
const result = new Map<
string,
{ id: string; posterThumbHash: string | null }
>();
for (const row of existing) {
result.set(browseTitleKey(row.tmdbId, row.type), {
id: row.id,
posterThumbHash: row.posterThumbHash,
});
}
// Find items that need inserting
const missingKeys = [...unique.keys()].filter((key) => !result.has(key));
if (missingKeys.length === 0) return result;
// Insert missing in a single transaction
db.transaction((tx) => {
for (const key of missingKeys) {
const item = unique.get(key);
if (!item) continue;
const row = tx
.insert(titles)
.values({
tmdbId: item.tmdbId,
type: item.type,
title: item.title,
overview: item.overview ?? null,
releaseDate: item.releaseDate ?? null,
firstAirDate: item.firstAirDate ?? null,
posterPath: item.posterPath,
backdropPath: item.backdropPath ?? null,
popularity: item.popularity ?? null,
voteAverage: item.voteAverage ?? null,
voteCount: item.voteCount ?? null,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning({ id: titles.id, posterThumbHash: titles.posterThumbHash })
.get();
if (row) {
result.set(key, {
id: row.id,
posterThumbHash: row.posterThumbHash,
});
}
}
// Fallback for any that conflicted (concurrent insert)
const stillMissing = missingKeys.filter((key) => !result.has(key));
if (stillMissing.length > 0) {
const missingTmdbIds = stillMissing.map(
(key) => unique.get(key)?.tmdbId ?? 0,
);
const fallbacks = tx
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(inArray(titles.tmdbId, missingTmdbIds))
.all();
for (const f of fallbacks) {
result.set(browseTitleKey(f.tmdbId, f.type), {
id: f.id,
posterThumbHash: f.posterThumbHash,
});
}
}
});
return result;
}
+79
View File
@@ -373,3 +373,82 @@ export async function fetchFullFilmography(
throw err;
}
}
// ─── Browse batch upsert ─────────────────────────────────────
interface BrowsePersonInput {
tmdbId: number;
name: string;
profilePath: string | null;
knownForDepartment?: string | null;
popularity?: number | null;
}
/**
* Ensure every person search result has a local person row.
* Inserts shell persons (`lastFetchedAt = null`) for new tmdbIds.
* Returns a map of tmdbId → internal UUID.
*/
export function ensureBrowsePersonsExist(
items: BrowsePersonInput[],
): Map<number, string> {
if (items.length === 0) return new Map();
const unique = new Map<number, BrowsePersonInput>();
for (const item of items) {
if (!unique.has(item.tmdbId)) unique.set(item.tmdbId, item);
}
const tmdbIds = [...unique.keys()];
const existing = db
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, tmdbIds))
.all();
const result = new Map<number, string>();
for (const row of existing) {
result.set(row.tmdbId, row.id);
}
const missing = tmdbIds.filter((id) => !result.has(id));
if (missing.length === 0) return result;
db.transaction((tx) => {
for (const tmdbId of missing) {
const item = unique.get(tmdbId);
if (!item) continue;
const row = tx
.insert(persons)
.values({
tmdbId: item.tmdbId,
name: item.name,
profilePath: item.profilePath,
knownForDepartment: item.knownForDepartment ?? null,
popularity: item.popularity ?? null,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning({ id: persons.id })
.get();
if (row) {
result.set(tmdbId, row.id);
}
}
const stillMissing = missing.filter((id) => !result.has(id));
if (stillMissing.length > 0) {
const fallbacks = tx
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, stillMissing))
.all();
for (const f of fallbacks) {
result.set(f.tmdbId, f.id);
}
}
});
return result;
}
+7 -41
View File
@@ -401,39 +401,6 @@ export function rateTitleStars(
.run();
}
export function getUserStatusesByTmdbIds(
userId: string,
tmdbIds: { tmdbId: number; type: string }[],
): Record<string, "watchlist" | "in_progress" | "completed"> {
if (tmdbIds.length === 0) return {};
const allTmdbIds = tmdbIds.map((t) => t.tmdbId);
const rows = db
.select({
tmdbId: titles.tmdbId,
type: titles.type,
status: userTitleStatus.status,
})
.from(userTitleStatus)
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
.where(
and(
eq(userTitleStatus.userId, userId),
inArray(titles.tmdbId, allTmdbIds),
),
)
.all();
const result: Record<string, "watchlist" | "in_progress" | "completed"> = {};
for (const row of rows) {
result[`${row.tmdbId}-${row.type}`] = row.status as
| "watchlist"
| "in_progress"
| "completed";
}
return result;
}
export function getUserStatusesByTitleIds(
userId: string,
titleIds: string[],
@@ -464,16 +431,15 @@ export function getUserStatusesByTitleIds(
return result;
}
export function getEpisodeProgressByTmdbIds(
export function getEpisodeProgressByTitleIds(
userId: string,
tmdbIds: { tmdbId: number; type: string }[],
titleIds: string[],
): Record<string, { watched: number; total: number }> {
const tvIds = tmdbIds.filter((t) => t.type === "tv").map((t) => t.tmdbId);
if (tvIds.length === 0) return {};
if (titleIds.length === 0) return {};
const rows = db
.select({
tmdbId: titles.tmdbId,
titleId: titles.id,
totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as(
"totalEpisodes",
),
@@ -492,14 +458,14 @@ export function getEpisodeProgressByTmdbIds(
eq(userEpisodeWatches.userId, userId),
),
)
.where(and(inArray(titles.tmdbId, tvIds), eq(titles.type, "tv")))
.groupBy(titles.tmdbId)
.where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
.groupBy(titles.id)
.all();
const result: Record<string, { watched: number; total: number }> = {};
for (const row of rows) {
if (row.watchedEpisodes > 0) {
result[`${row.tmdbId}-tv`] = {
result[row.titleId] = {
watched: row.watchedEpisodes,
total: row.totalEpisodes,
};
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS `titles_tmdbId_unique`;--> statement-breakpoint
CREATE UNIQUE INDEX `titles_tmdbId_type_unique` ON `titles` (`tmdbId`,`type`);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
ALTER TABLE `titles` ADD `imdbId` text;--> statement-breakpoint
ALTER TABLE `titles` ADD `originalLanguage` text;--> statement-breakpoint
ALTER TABLE `titles` ADD `runtimeMinutes` integer;
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -118,12 +118,15 @@ export const titles = sqliteTable(
voteCount: int("voteCount"),
status: text("status"),
contentRating: text("contentRating"),
imdbId: text("imdbId"),
originalLanguage: text("originalLanguage"),
runtimeMinutes: int("runtimeMinutes"),
colorPalette: text("colorPalette"),
trailerVideoKey: text("trailerVideoKey"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titles_tmdbId_unique").on(table.tmdbId),
uniqueIndex("titles_tmdbId_type_unique").on(table.tmdbId, table.type),
index("titles_type_releaseDate").on(table.type, table.releaseDate),
index("titles_type_firstAirDate").on(table.type, table.firstAirDate),
index("titles_lastFetchedAt").on(table.lastFetchedAt),