mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
perf(metadata): remember empty TMDB lookups instead of refetching every view
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
15 files changed
+3699
-29
No files matched your search
@@ -3,6 +3,7 @@ import {
|
||||
getAvailabilityForTitle,
|
||||
replaceAvailabilityTransaction,
|
||||
} from "@sofa/db/queries/availability";
|
||||
import { markEnrichmentChecked } from "@sofa/db/queries/metadata";
|
||||
import { getTitleById } from "@sofa/db/queries/title";
|
||||
import type { titleAvailability } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
@@ -42,6 +43,7 @@ export async function refreshAvailability(titleId: string) {
|
||||
}
|
||||
|
||||
replaceAvailabilityTransaction(titleId, "US", allOfferRows);
|
||||
markEnrichmentChecked(titleId, "availability", now);
|
||||
|
||||
if (!us) {
|
||||
log.debug(`No US providers for title ${titleId}; cleared cached offers`);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getPersonsForTitleCast,
|
||||
replaceTitleCastTransaction,
|
||||
} from "@sofa/db/queries/credits";
|
||||
import { markEnrichmentChecked } from "@sofa/db/queries/metadata";
|
||||
import { getTitleById } from "@sofa/db/queries/title";
|
||||
import type { titleCast } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
@@ -249,6 +250,7 @@ export async function refreshCredits(titleId: string) {
|
||||
if (allCastRows.length > 0) replaceTitleCastTransaction(titleId, allCastRows);
|
||||
}
|
||||
|
||||
markEnrichmentChecked(titleId, "credits");
|
||||
log.debug(`Credits refreshed for "${title.title}"`);
|
||||
|
||||
(async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getStaleTitles,
|
||||
getStaleNonLibraryTitles,
|
||||
getTitleByIdForCron,
|
||||
getTitleIdsCheckedBefore,
|
||||
getTitleIdsWithStaleSeasons,
|
||||
getTitlesWithFreshRecommendations,
|
||||
getTitlesWithStaleOffers,
|
||||
@@ -82,6 +83,7 @@ export {
|
||||
getCastEntryForTitle,
|
||||
getReturningTvShows,
|
||||
getTitleByIdForCron,
|
||||
getTitleIdsCheckedBefore,
|
||||
getTitleIdsWithStaleSeasons,
|
||||
getTitlesWithFreshRecommendations,
|
||||
};
|
||||
@@ -6,11 +6,17 @@ import {
|
||||
getSonarrShows,
|
||||
resolveListIntegration,
|
||||
} from "@sofa/db/queries/lists";
|
||||
import { markEnrichmentChecked } from "@sofa/db/queries/metadata";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { getTvExternalIds } from "@sofa/tmdb/client";
|
||||
|
||||
import { mapWithConcurrency } from "./concurrency";
|
||||
|
||||
const log = createLogger("lists");
|
||||
|
||||
const TVDB_RECHECK_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TVDB_LOOKUP_CONCURRENCY = 4;
|
||||
|
||||
/** Look up a list integration token and return the userId + provider, or null. */
|
||||
export function resolveListToken(
|
||||
token: string,
|
||||
@@ -54,23 +60,32 @@ export async function getSonarrList(
|
||||
] as Status[];
|
||||
const rows = getSonarrShows(userId, mappedStatuses);
|
||||
|
||||
// Resolve missing TVDB IDs in parallel instead of sequentially
|
||||
const needsResolution = rows.filter((r) => r.tvdbId == null);
|
||||
// Resolve missing TVDB IDs with bounded concurrency; skip shows TMDB recently had no answer for
|
||||
const needsResolution = rows.filter(
|
||||
(r) =>
|
||||
r.tvdbId == null &&
|
||||
(!r.tvdbCheckedAt || Date.now() - r.tvdbCheckedAt.getTime() >= TVDB_RECHECK_MS),
|
||||
);
|
||||
if (needsResolution.length > 0) {
|
||||
const resolved = await Promise.all(
|
||||
needsResolution.map(async (row) => {
|
||||
const settled = await mapWithConcurrency(
|
||||
needsResolution,
|
||||
async (row) => {
|
||||
try {
|
||||
const externalIds = await getTvExternalIds(row.tmdbId);
|
||||
return { row, tvdbId: externalIds.tvdb_id };
|
||||
return { row, tvdbId: externalIds.tvdb_id ?? null, ok: true };
|
||||
} catch (err) {
|
||||
log.warn(`Failed to resolve TVDB ID for TMDB ${row.tmdbId}:`, err);
|
||||
return { row, tvdbId: null };
|
||||
return { row, tvdbId: null, ok: false };
|
||||
}
|
||||
}),
|
||||
},
|
||||
TVDB_LOOKUP_CONCURRENCY,
|
||||
);
|
||||
// Batch update resolved IDs
|
||||
const updates: { titleId: string; tvdbId: number }[] = [];
|
||||
for (const { row, tvdbId } of resolved) {
|
||||
for (const result of settled) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
const { row, tvdbId, ok } = result.value;
|
||||
if (ok) markEnrichmentChecked(row.id, "tvdb");
|
||||
if (tvdbId != null) {
|
||||
row.tvdbId = tvdbId;
|
||||
updates.push({ titleId: row.id, tvdbId });
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
hasRecommendationsForTitle,
|
||||
hasSeasonForTitle,
|
||||
insertTitleReturning,
|
||||
markEnrichmentChecked,
|
||||
nullifyEpisodeThumbHash,
|
||||
nullifySeasonThumbHash,
|
||||
updateTitleFields,
|
||||
@@ -65,6 +66,13 @@ import {
|
||||
|
||||
const log = createLogger("metadata");
|
||||
|
||||
/** After an empty TMDB answer, wait this long before asking again. */
|
||||
const EMPTY_ENRICHMENT_RECHECK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function checkedRecently(at: Date | null | undefined): boolean {
|
||||
return !!at && Date.now() - at.getTime() < EMPTY_ENRICHMENT_RECHECK_MS;
|
||||
}
|
||||
|
||||
const THUMBHASH_CONCURRENCY = 4;
|
||||
|
||||
export function updateTitleWithArtInvalidation(
|
||||
@@ -422,6 +430,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
getRecommendations(title.tmdbId, title.type),
|
||||
getSimilar(title.tmdbId, title.type),
|
||||
]);
|
||||
markEnrichmentChecked(titleId, "recommendations");
|
||||
|
||||
const recsResults = recs.results ?? [];
|
||||
const similarResults = similar.results ?? [];
|
||||
@@ -606,13 +615,13 @@ async function ensureEnriched(
|
||||
): Promise<boolean> {
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (!existing.hasCast) {
|
||||
if (!existing.hasCast && !checkedRecently(title.creditsCheckedAt)) {
|
||||
tasks.push(
|
||||
refreshCredits(titleId).catch((err) => log.debug("Credits enrichment failed:", err)),
|
||||
);
|
||||
}
|
||||
|
||||
if (!existing.hasAvailability) {
|
||||
if (!existing.hasAvailability && !checkedRecently(title.availabilityCheckedAt)) {
|
||||
tasks.push(
|
||||
refreshAvailability(titleId).catch((err) =>
|
||||
log.debug("Availability enrichment failed:", err),
|
||||
@@ -621,7 +630,7 @@ async function ensureEnriched(
|
||||
}
|
||||
|
||||
// Recommendations are loaded separately (Suspense), so check here
|
||||
if (!hasRecommendationsForTitle(titleId)) {
|
||||
if (!hasRecommendationsForTitle(titleId) && !checkedRecently(title.recommendationsCheckedAt)) {
|
||||
tasks.push(
|
||||
refreshRecommendations(titleId).catch((err) =>
|
||||
log.debug("Recommendations enrichment failed:", err),
|
||||
@@ -646,7 +655,7 @@ async function ensureEnriched(
|
||||
);
|
||||
}
|
||||
|
||||
if (!title.trailerVideoKey) {
|
||||
if (!title.trailerVideoKey && !checkedRecently(title.trailerCheckedAt)) {
|
||||
tasks.push(
|
||||
refreshTrailer(titleId).catch((err) => log.debug("Trailer enrichment failed:", err)),
|
||||
);
|
||||
@@ -866,6 +875,7 @@ export async function refreshTrailer(titleId: string) {
|
||||
const response = await getVideos(title.tmdbId, title.type);
|
||||
const key = pickBestTrailer(response.results ?? []);
|
||||
updateTrailerKey(titleId, key);
|
||||
markEnrichmentChecked(titleId, "trailer");
|
||||
log.debug(`Trailer for "${title.title}": ${key ? `YouTube ${key}` : "none found"}`);
|
||||
} catch (err) {
|
||||
log.debug(`Failed to fetch trailer for title ${titleId}:`, err);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { titleAvailability } from "@sofa/db/schema";
|
||||
import { titleAvailability, titles } from "@sofa/db/schema";
|
||||
import {
|
||||
clearAllTables,
|
||||
eq,
|
||||
@@ -43,4 +43,13 @@ describe("refreshAvailability", () => {
|
||||
|
||||
expect(offers).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("records availabilityCheckedAt even when TMDB has no providers", async () => {
|
||||
insertTitle({ id: "movie-2", tmdbId: 102, type: "movie", title: "Movie 2" });
|
||||
|
||||
await refreshAvailability("movie-2");
|
||||
|
||||
const row = testDb.select().from(titles).where(eq(titles.id, "movie-2")).get();
|
||||
expect(row?.availabilityCheckedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { getTitleIdsCheckedBefore } from "@sofa/db/queries/cron";
|
||||
import { titles } from "@sofa/db/schema";
|
||||
import { clearAllTables, eq, insertTitle, testDb } from "@sofa/test/db";
|
||||
|
||||
const { getVideos } = vi.hoisted(() => ({
|
||||
getVideos: vi.fn<() => Promise<{ results: unknown[] }>>(async () => ({ results: [] })),
|
||||
}));
|
||||
|
||||
vi.mock("@sofa/tmdb/client", () => ({
|
||||
getVideos,
|
||||
getRecommendations: async () => ({ results: [] }),
|
||||
getSimilar: async () => ({ results: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../src/credits", () => ({
|
||||
refreshCredits: async () => {},
|
||||
getCastForTitle: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../src/availability", () => ({
|
||||
refreshAvailability: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../src/image-cache", () => ({
|
||||
cacheEpisodeStills: async () => {},
|
||||
cacheImagesForTitle: async () => {},
|
||||
deleteOrphanedImage: async () => {},
|
||||
imageCacheEnabled: () => false,
|
||||
loadImageBuffer: async () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../src/thumbhash", () => ({
|
||||
generateEpisodeThumbHash: async () => {},
|
||||
generateSeasonThumbHash: async () => {},
|
||||
generateTitleBackdropThumbHash: async () => {},
|
||||
generateTitlePosterThumbHash: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../src/colors", () => ({
|
||||
extractAndStoreColors: async () => {},
|
||||
parseColorPalette: () => null,
|
||||
}));
|
||||
|
||||
import { getOrFetchTitle, refreshTrailer } from "../src/metadata";
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function getTitle(id: string) {
|
||||
return testDb.select().from(titles).where(eq(titles.id, id)).get();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
getVideos.mockReset();
|
||||
getVideos.mockImplementation(async () => ({ results: [] }));
|
||||
});
|
||||
|
||||
describe("refreshTrailer", () => {
|
||||
test("stamps trailerCheckedAt even when TMDB has no trailer", async () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1, type: "movie", title: "M" });
|
||||
|
||||
await refreshTrailer("m1");
|
||||
|
||||
const row = getTitle("m1");
|
||||
expect(row?.trailerVideoKey).toBeNull();
|
||||
expect(row?.trailerCheckedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("does not stamp when the TMDB call fails", async () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1, type: "movie", title: "M" });
|
||||
getVideos.mockRejectedValue(new Error("network"));
|
||||
|
||||
await refreshTrailer("m1");
|
||||
|
||||
expect(getTitle("m1")?.trailerCheckedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTitleIdsCheckedBefore", () => {
|
||||
test("returns titles with a null or old stamp only", () => {
|
||||
insertTitle({ id: "t-null", tmdbId: 1, type: "movie" });
|
||||
insertTitle({ id: "t-old", tmdbId: 2, type: "movie" });
|
||||
insertTitle({ id: "t-new", tmdbId: 3, type: "movie" });
|
||||
testDb
|
||||
.update(titles)
|
||||
.set({ creditsCheckedAt: new Date(Date.now() - 40 * DAY) })
|
||||
.where(eq(titles.id, "t-old"))
|
||||
.run();
|
||||
testDb.update(titles).set({ creditsCheckedAt: new Date() }).where(eq(titles.id, "t-new")).run();
|
||||
|
||||
const ids = getTitleIdsCheckedBefore(
|
||||
["t-null", "t-old", "t-new"],
|
||||
"creditsCheckedAt",
|
||||
new Date(Date.now() - 30 * DAY),
|
||||
);
|
||||
|
||||
expect(ids.toSorted()).toEqual(["t-null", "t-old"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrFetchTitle trailer gating", () => {
|
||||
test("skips the trailer lookup until the empty answer is a week old", async () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1, type: "movie", title: "M" });
|
||||
testDb
|
||||
.update(titles)
|
||||
.set({ lastFetchedAt: new Date(), trailerCheckedAt: new Date() })
|
||||
.where(eq(titles.id, "m1"))
|
||||
.run();
|
||||
|
||||
await getOrFetchTitle("m1");
|
||||
expect(getVideos).not.toHaveBeenCalled();
|
||||
|
||||
testDb
|
||||
.update(titles)
|
||||
.set({ trailerCheckedAt: new Date(Date.now() - 8 * DAY) })
|
||||
.where(eq(titles.id, "m1"))
|
||||
.run();
|
||||
|
||||
await getOrFetchTitle("m1");
|
||||
expect(getVideos).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -177,6 +177,18 @@ describe("getSonarrList", () => {
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
|
||||
test("remembers a null TVDB ID instead of looking it up again", async () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({ id: "tv1", tmdbId: 300, type: "tv", title: "Show C" });
|
||||
insertStatus("user-1", "tv1", "watchlist");
|
||||
|
||||
mockGetTvExternalIds.mockResolvedValueOnce({ tvdb_id: null, imdb_id: null });
|
||||
|
||||
expect(await getSonarrList("user-1")).toEqual([]);
|
||||
expect(await getSonarrList("user-1")).toEqual([]);
|
||||
expect(mockGetTvExternalIds).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("filters by status", async () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({
|
||||
|
||||
Reference in new issue
Block a user