mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
feat: generate and display thumbhash blur placeholders for TMDB images
This commit is contained in:
49 files changed
+4294
-287
No files matched your search
@@ -156,6 +156,7 @@ const EpisodeSchema = z.object({
|
||||
name: z.string().nullable(),
|
||||
overview: z.string().nullable(),
|
||||
stillPath: z.string().nullable(),
|
||||
stillThumbHash: z.string().nullable(),
|
||||
airDate: z.string().nullable(),
|
||||
runtimeMinutes: z.number().nullable(),
|
||||
});
|
||||
@@ -185,6 +186,7 @@ const CastMemberSchema = z.object({
|
||||
displayOrder: z.number(),
|
||||
episodeCount: z.number().nullable(),
|
||||
profilePath: z.string().nullable(),
|
||||
profileThumbHash: z.string().nullable(),
|
||||
tmdbId: z.number(),
|
||||
});
|
||||
|
||||
@@ -198,7 +200,9 @@ const ResolvedTitleSchema = z.object({
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
posterPath: z.string().nullable(),
|
||||
posterThumbHash: z.string().nullable(),
|
||||
backdropPath: z.string().nullable(),
|
||||
backdropThumbHash: z.string().nullable(),
|
||||
popularity: z.number().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
voteCount: z.number().nullable(),
|
||||
@@ -218,6 +222,7 @@ const PersonSchema = z.object({
|
||||
deathday: z.string().nullable(),
|
||||
placeOfBirth: z.string().nullable(),
|
||||
profilePath: z.string().nullable(),
|
||||
profileThumbHash: z.string().nullable(),
|
||||
knownForDepartment: z.string().nullable(),
|
||||
imdbId: z.string().nullable(),
|
||||
});
|
||||
@@ -228,6 +233,7 @@ const PersonCreditSchema = z.object({
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
posterThumbHash: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
@@ -242,6 +248,7 @@ const TmdbBrowseItem = z.object({
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
posterThumbHash: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
@@ -254,6 +261,7 @@ const RecommendationItemSchema = z.object({
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
posterThumbHash: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
@@ -324,6 +332,7 @@ export const ContinueWatchingOutput = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
backdropPath: z.string().nullable(),
|
||||
backdropThumbHash: z.string().nullable(),
|
||||
}),
|
||||
nextEpisode: z
|
||||
.object({
|
||||
@@ -331,6 +340,7 @@ export const ContinueWatchingOutput = z.object({
|
||||
episodeNumber: z.number(),
|
||||
name: z.string().nullable(),
|
||||
stillPath: z.string().nullable(),
|
||||
stillThumbHash: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
totalEpisodes: z.number(),
|
||||
@@ -347,6 +357,7 @@ export const LibraryOutput = z.object({
|
||||
type: mediaType,
|
||||
title: z.string(),
|
||||
posterPath: z.string().nullable(),
|
||||
posterThumbHash: z.string().nullable(),
|
||||
releaseDate: z.string().nullable(),
|
||||
firstAirDate: z.string().nullable(),
|
||||
voteAverage: z.number().nullable(),
|
||||
|
||||
@@ -6,6 +6,7 @@ interface NextEpisodeInfo {
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
stillPath: string | null;
|
||||
stillThumbHash: string | null;
|
||||
}
|
||||
|
||||
export interface NextEpisodeResult {
|
||||
@@ -41,6 +42,7 @@ export function getNextEpisode(
|
||||
episodeNumber: ep.episodeNumber,
|
||||
name: ep.name,
|
||||
stillPath: ep.stillPath,
|
||||
stillThumbHash: ep.stillThumbHash,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"./settings": "./src/settings.ts",
|
||||
"./system-health": "./src/system-health.ts",
|
||||
"./telemetry": "./src/telemetry.ts",
|
||||
"./thumbhash": "./src/thumbhash.ts",
|
||||
"./tracking": "./src/tracking.ts",
|
||||
"./update-check": "./src/update-check.ts",
|
||||
"./webhooks": "./src/webhooks.ts"
|
||||
@@ -35,6 +36,8 @@
|
||||
"@sofa/tmdb": "workspace:*",
|
||||
"date-fns": "catalog:",
|
||||
"node-vibrant": "4.0.4",
|
||||
"sharp": "0.34.5",
|
||||
"thumbhash": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+14
-24
@@ -1,15 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { Vibrant } from "node-vibrant/node";
|
||||
import {
|
||||
downloadAndCacheImage,
|
||||
getLocalImagePath,
|
||||
imageCacheEnabled,
|
||||
isImageCached,
|
||||
} from "./image-cache";
|
||||
import { loadImageBuffer } from "./image-cache";
|
||||
|
||||
const log = createLogger("colors");
|
||||
|
||||
@@ -25,27 +19,23 @@ export interface ColorPalette {
|
||||
export async function extractAndStoreColors(
|
||||
titleId: string,
|
||||
posterPath: string | null,
|
||||
sourceBuffer?: Buffer | null,
|
||||
): Promise<ColorPalette | null> {
|
||||
if (!posterPath) return null;
|
||||
|
||||
// Use local cached image, downloading first if needed
|
||||
let source: string;
|
||||
const filename = path.basename(posterPath);
|
||||
if (imageCacheEnabled()) {
|
||||
if (!(await isImageCached("posters", filename))) {
|
||||
await downloadAndCacheImage(posterPath, "posters");
|
||||
}
|
||||
source = getLocalImagePath("posters", filename);
|
||||
} else {
|
||||
const baseUrl =
|
||||
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||
source = `${baseUrl}/w300${posterPath}`;
|
||||
if (!posterPath) {
|
||||
db.update(titles)
|
||||
.set({ colorPalette: null })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
return null;
|
||||
}
|
||||
|
||||
const source =
|
||||
sourceBuffer === undefined
|
||||
? await loadImageBuffer(posterPath, "posters")
|
||||
: sourceBuffer;
|
||||
if (!source) return null;
|
||||
|
||||
try {
|
||||
log.debug(
|
||||
`Extracting colors for title ${titleId} from ${imageCacheEnabled() ? "cache" : "remote"}`,
|
||||
);
|
||||
const palette = await Vibrant.from(source).getPalette();
|
||||
|
||||
const colors: ColorPalette = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createLogger } from "@sofa/logger";
|
||||
import { getMovieCredits, getTvAggregateCredits } from "@sofa/tmdb/client";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache";
|
||||
import { generatePersonThumbHash } from "./thumbhash";
|
||||
|
||||
const log = createLogger("credits");
|
||||
|
||||
@@ -37,49 +38,107 @@ function batchUpsertPersons(people: PersonData[]): Map<number, string> {
|
||||
|
||||
// Batch prefetch existing persons (1 query)
|
||||
const existing = db
|
||||
.select({ id: persons.id, tmdbId: persons.tmdbId })
|
||||
.select({
|
||||
id: persons.id,
|
||||
tmdbId: persons.tmdbId,
|
||||
name: persons.name,
|
||||
profilePath: persons.profilePath,
|
||||
profileThumbHash: persons.profileThumbHash,
|
||||
popularity: persons.popularity,
|
||||
})
|
||||
.from(persons)
|
||||
.where(inArray(persons.tmdbId, tmdbIds))
|
||||
.all();
|
||||
const existingMap = new Map(existing.map((p) => [p.tmdbId, p]));
|
||||
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,
|
||||
db.transaction((tx) => {
|
||||
for (const p of uniquePeople) {
|
||||
const existingPerson = existingMap.get(p.tmdbId);
|
||||
if (existingPerson) {
|
||||
const nextPopularity = p.popularity ?? null;
|
||||
const pathChanged = existingPerson.profilePath !== p.profilePath;
|
||||
const needsUpdate =
|
||||
existingPerson.name !== p.name ||
|
||||
pathChanged ||
|
||||
existingPerson.popularity !== nextPopularity;
|
||||
if (!needsUpdate) continue;
|
||||
|
||||
tx.update(persons)
|
||||
.set({
|
||||
name: p.name,
|
||||
profilePath: p.profilePath,
|
||||
popularity: p.popularity ?? null,
|
||||
popularity: nextPopularity,
|
||||
profileThumbHash: pathChanged
|
||||
? null
|
||||
: existingPerson.profileThumbHash,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get();
|
||||
if (row) idMap.set(p.tmdbId, row.id);
|
||||
.where(eq(persons.id, existingPerson.id))
|
||||
.run();
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
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 concurrent inserts that conflicted
|
||||
const stillMissing = uniquePeople
|
||||
.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 syncCastProfileThumbHashes(
|
||||
titleId: string,
|
||||
personIds?: string[],
|
||||
options?: { warmCache?: boolean },
|
||||
) {
|
||||
if (options?.warmCache !== false && imageCacheEnabled()) {
|
||||
await cacheProfilePhotos(titleId);
|
||||
}
|
||||
|
||||
const personRows = db
|
||||
.select({
|
||||
id: persons.id,
|
||||
profilePath: persons.profilePath,
|
||||
profileThumbHash: persons.profileThumbHash,
|
||||
})
|
||||
.from(titleCast)
|
||||
.innerJoin(persons, eq(titleCast.personId, persons.id))
|
||||
.where(eq(titleCast.titleId, titleId))
|
||||
.all();
|
||||
|
||||
const allowedIds = personIds ? new Set(personIds) : null;
|
||||
const uniqueRows = new Map(personRows.map((p) => [p.id, p]));
|
||||
await Promise.all(
|
||||
[...uniqueRows.values()]
|
||||
.filter((p) => (!allowedIds || allowedIds.has(p.id)) && p.profilePath)
|
||||
.filter((p) => !p.profileThumbHash)
|
||||
.map((p) => generatePersonThumbHash(p.id, p.profilePath)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function refreshCredits(titleId: string) {
|
||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
if (!title) return;
|
||||
@@ -276,11 +335,9 @@ export async function refreshCredits(titleId: string) {
|
||||
|
||||
log.debug(`Credits refreshed for "${title.title}"`);
|
||||
|
||||
if (imageCacheEnabled()) {
|
||||
cacheProfilePhotos(titleId).catch((err) =>
|
||||
log.debug("Profile photo caching failed:", err),
|
||||
);
|
||||
}
|
||||
(async () => {
|
||||
await syncCastProfileThumbHashes(titleId, [...personIds.values()]);
|
||||
})().catch((err) => log.debug("Profile cache/thumbhash failed:", err));
|
||||
} catch (err) {
|
||||
log.error(`Failed to refresh credits for title ${titleId}:`, err);
|
||||
}
|
||||
@@ -298,6 +355,7 @@ export function getCastForTitle(titleId: string): CastMember[] {
|
||||
displayOrder: titleCast.displayOrder,
|
||||
episodeCount: titleCast.episodeCount,
|
||||
profilePath: persons.profilePath,
|
||||
profileThumbHash: persons.profileThumbHash,
|
||||
tmdbId: persons.tmdbId,
|
||||
})
|
||||
.from(titleCast)
|
||||
|
||||
@@ -177,6 +177,7 @@ export interface ContinueWatchingItem {
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
backdropPath: string | null;
|
||||
backdropThumbHash: string | null;
|
||||
type: string;
|
||||
};
|
||||
nextEpisode: {
|
||||
@@ -185,6 +186,7 @@ export interface ContinueWatchingItem {
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
stillPath: string | null;
|
||||
stillThumbHash: string | null;
|
||||
overview: string | null;
|
||||
} | null;
|
||||
lastWatchedAt: Date | null;
|
||||
@@ -322,6 +324,7 @@ export function getContinueWatchingFeed(
|
||||
episodeNumber: ep.episodeNumber,
|
||||
name: ep.name,
|
||||
stillPath: ep.stillPath,
|
||||
stillThumbHash: ep.stillThumbHash,
|
||||
overview: ep.overview,
|
||||
};
|
||||
}
|
||||
@@ -335,6 +338,7 @@ export function getContinueWatchingFeed(
|
||||
title: title.title,
|
||||
posterPath: title.posterPath,
|
||||
backdropPath: title.backdropPath,
|
||||
backdropThumbHash: title.backdropThumbHash,
|
||||
type: title.type,
|
||||
},
|
||||
nextEpisode,
|
||||
@@ -366,6 +370,7 @@ export function getNewAvailableFeed(userId: string, days = 14) {
|
||||
type: titles.type,
|
||||
tmdbId: titles.tmdbId,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
releaseDate: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
voteAverage: titles.voteAverage,
|
||||
@@ -524,6 +529,7 @@ export function getRecommendationsForTitle(titleId: string) {
|
||||
type: r.type as "movie" | "tv",
|
||||
title: r.title,
|
||||
posterPath: tmdbImageUrl(r.posterPath, "posters"),
|
||||
posterThumbHash: r.posterThumbHash,
|
||||
releaseDate: r.releaseDate,
|
||||
firstAirDate: r.firstAirDate,
|
||||
voteAverage: r.voteAverage,
|
||||
|
||||
@@ -12,26 +12,22 @@ import {
|
||||
titles,
|
||||
} from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import type { ImageCategory } from "@sofa/tmdb/image";
|
||||
import {
|
||||
IMAGE_CATEGORY_SIZES,
|
||||
type ImageCategory,
|
||||
tmdbCdnImageUrl,
|
||||
} from "@sofa/tmdb/image";
|
||||
|
||||
const log = createLogger("image-cache");
|
||||
|
||||
export type { ImageCategory };
|
||||
|
||||
const CATEGORY_SIZES: Record<ImageCategory, string> = {
|
||||
posters: "w500",
|
||||
backdrops: "w1280",
|
||||
stills: "w1280",
|
||||
logos: "w92",
|
||||
profiles: "w185",
|
||||
};
|
||||
|
||||
export function imageCacheEnabled(): boolean {
|
||||
return process.env.IMAGE_CACHE_ENABLED !== "false";
|
||||
}
|
||||
|
||||
export async function ensureImageDirs() {
|
||||
for (const category of Object.keys(CATEGORY_SIZES)) {
|
||||
for (const category of Object.keys(IMAGE_CATEGORY_SIZES) as ImageCategory[]) {
|
||||
await mkdir(path.join(CACHE_DIR, category), { recursive: true });
|
||||
}
|
||||
}
|
||||
@@ -60,20 +56,33 @@ export async function readCachedImage(
|
||||
return Buffer.from(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
async function fetchRemoteImage(
|
||||
tmdbPath: string,
|
||||
category: ImageCategory,
|
||||
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||
const url =
|
||||
tmdbCdnImageUrl(tmdbPath, category) ?? `${TMDB_IMAGE_BASE_URL}${tmdbPath}`;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
log.warn(`Fetch failed: ${url} -> ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(await res.arrayBuffer()),
|
||||
contentType: res.headers.get("content-type") || "image/jpeg",
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadAndCacheImage(
|
||||
tmdbPath: string,
|
||||
category: ImageCategory,
|
||||
): Promise<Buffer | null> {
|
||||
const size = CATEGORY_SIZES[category];
|
||||
const url = `${TMDB_IMAGE_BASE_URL}/${size}${tmdbPath}`;
|
||||
const remote = await fetchRemoteImage(tmdbPath, category);
|
||||
if (!remote) return null;
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
log.warn(`Download failed: ${url} -> ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const { buffer } = remote;
|
||||
const filename = path.basename(tmdbPath);
|
||||
const finalPath = getLocalImagePath(category, filename);
|
||||
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
|
||||
@@ -109,16 +118,10 @@ export async function fetchAndMaybeCache(
|
||||
}
|
||||
|
||||
// Fetch from TMDB
|
||||
const size = CATEGORY_SIZES[category];
|
||||
const url = `${TMDB_IMAGE_BASE_URL}/${size}${tmdbPath}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
log.warn(`Fetch failed: ${url} -> ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
const remote = await fetchRemoteImage(tmdbPath, category);
|
||||
if (!remote) return null;
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const contentType = res.headers.get("content-type") || "image/jpeg";
|
||||
const { buffer, contentType } = remote;
|
||||
|
||||
// Fire-and-forget save to disk
|
||||
const finalPath = getLocalImagePath(category, filename);
|
||||
@@ -130,6 +133,22 @@ export async function fetchAndMaybeCache(
|
||||
return { buffer, contentType };
|
||||
}
|
||||
|
||||
export async function loadImageBuffer(
|
||||
tmdbPath: string,
|
||||
category: ImageCategory,
|
||||
): Promise<Buffer | null> {
|
||||
const filename = path.basename(tmdbPath);
|
||||
|
||||
if (imageCacheEnabled()) {
|
||||
const cached = await readCachedImage(category, filename);
|
||||
if (cached) return cached;
|
||||
return downloadAndCacheImage(tmdbPath, category);
|
||||
}
|
||||
|
||||
const remote = await fetchRemoteImage(tmdbPath, category);
|
||||
return remote?.buffer ?? null;
|
||||
}
|
||||
|
||||
export async function cacheImagesForTitle(titleId: string) {
|
||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
if (!title) return;
|
||||
|
||||
+402
-143
@@ -6,7 +6,7 @@ import type {
|
||||
Season,
|
||||
} from "@sofa/api/schemas";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { eq, inArray, sql } from "@sofa/db/helpers";
|
||||
import { and, eq, inArray, isNotNull, sql } from "@sofa/db/helpers";
|
||||
import {
|
||||
availabilityOffers,
|
||||
episodes,
|
||||
@@ -39,8 +39,15 @@ import {
|
||||
cacheEpisodeStills,
|
||||
cacheImagesForTitle,
|
||||
imageCacheEnabled,
|
||||
loadImageBuffer,
|
||||
} from "./image-cache";
|
||||
import { generateProviderUrl } from "./providers";
|
||||
import {
|
||||
generateEpisodeThumbHash,
|
||||
generateSeasonThumbHash,
|
||||
generateTitleBackdropThumbHash,
|
||||
generateTitlePosterThumbHash,
|
||||
} from "./thumbhash";
|
||||
|
||||
const log = createLogger("metadata");
|
||||
|
||||
@@ -86,6 +93,47 @@ function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTitleWithArtInvalidation(
|
||||
title: Pick<
|
||||
typeof titles.$inferSelect,
|
||||
| "id"
|
||||
| "posterPath"
|
||||
| "backdropPath"
|
||||
| "posterThumbHash"
|
||||
| "backdropThumbHash"
|
||||
| "colorPalette"
|
||||
>,
|
||||
values: Partial<typeof titles.$inferInsert>,
|
||||
) {
|
||||
const nextPosterPath = Object.hasOwn(values, "posterPath")
|
||||
? (values.posterPath ?? null)
|
||||
: title.posterPath;
|
||||
const nextBackdropPath = Object.hasOwn(values, "backdropPath")
|
||||
? (values.backdropPath ?? null)
|
||||
: title.backdropPath;
|
||||
|
||||
const posterPathChanged = nextPosterPath !== title.posterPath;
|
||||
const backdropPathChanged = nextBackdropPath !== title.backdropPath;
|
||||
|
||||
db.update(titles)
|
||||
.set({
|
||||
...values,
|
||||
...(posterPathChanged
|
||||
? {
|
||||
posterThumbHash: null,
|
||||
colorPalette: null,
|
||||
}
|
||||
: {}),
|
||||
...(backdropPathChanged
|
||||
? {
|
||||
backdropThumbHash: null,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.where(eq(titles.id, title.id))
|
||||
.run();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function extractMovieContentRating(
|
||||
movie: TmdbMovieDetails,
|
||||
@@ -148,18 +196,15 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
if (!hasSeason) {
|
||||
const show = await getTvDetails(tmdbId);
|
||||
if (!existing.lastFetchedAt) {
|
||||
db.update(titles)
|
||||
.set({
|
||||
overview: show.overview,
|
||||
posterPath: show.poster_path,
|
||||
backdropPath: show.backdrop_path,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.where(eq(titles.id, existing.id))
|
||||
.run();
|
||||
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);
|
||||
@@ -169,23 +214,18 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
refreshRecommendations(existing.id).catch((err) =>
|
||||
log.debug("Recommendations enrichment failed:", err),
|
||||
);
|
||||
extractAndStoreColors(existing.id, show.poster_path ?? null).catch(
|
||||
(err) => log.debug("Color extraction failed:", err),
|
||||
);
|
||||
syncTitleArt(
|
||||
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),
|
||||
);
|
||||
if (imageCacheEnabled()) {
|
||||
cacheImagesForTitle(existing.id).catch((err) =>
|
||||
log.debug("Image caching failed:", err),
|
||||
);
|
||||
cacheEpisodeStills(existing.id).catch((err) =>
|
||||
log.debug("Episode stills caching failed:", err),
|
||||
);
|
||||
}
|
||||
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
|
||||
}
|
||||
}
|
||||
@@ -223,8 +263,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
refreshRecommendations(row.id).catch((err) =>
|
||||
log.debug("Recommendations enrichment failed:", err),
|
||||
);
|
||||
extractAndStoreColors(row.id, movie.poster_path ?? null).catch((err) =>
|
||||
log.debug("Color extraction 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),
|
||||
@@ -232,11 +272,6 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
refreshTrailer(row.id).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
);
|
||||
if (imageCacheEnabled()) {
|
||||
cacheImagesForTitle(row.id).catch((err) =>
|
||||
log.debug("Image caching failed:", err),
|
||||
);
|
||||
}
|
||||
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
|
||||
return row;
|
||||
}
|
||||
@@ -272,8 +307,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
refreshRecommendations(row.id).catch((err) =>
|
||||
log.debug("Recommendations enrichment failed:", err),
|
||||
);
|
||||
extractAndStoreColors(row.id, show.poster_path ?? null).catch((err) =>
|
||||
log.debug("Color extraction 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),
|
||||
@@ -281,14 +316,6 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||
refreshTrailer(row.id).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
);
|
||||
if (imageCacheEnabled()) {
|
||||
cacheImagesForTitle(row.id).catch((err) =>
|
||||
log.debug("Image caching failed:", err),
|
||||
);
|
||||
cacheEpisodeStills(row.id).catch((err) =>
|
||||
log.debug("Episode stills caching failed:", err),
|
||||
);
|
||||
}
|
||||
log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`);
|
||||
return row;
|
||||
}
|
||||
@@ -301,69 +328,56 @@ export async function refreshTitle(titleId: string) {
|
||||
|
||||
if (title.type === "movie") {
|
||||
const movie = await getMovieDetails(title.tmdbId);
|
||||
db.update(titles)
|
||||
.set({
|
||||
title: movie.title,
|
||||
originalTitle: movie.original_title,
|
||||
overview: movie.overview,
|
||||
releaseDate: movie.release_date || null,
|
||||
posterPath: movie.poster_path,
|
||||
backdropPath: movie.backdrop_path,
|
||||
popularity: movie.popularity,
|
||||
voteAverage: movie.vote_average,
|
||||
voteCount: movie.vote_count,
|
||||
status: movie.status,
|
||||
contentRating: extractMovieContentRating(movie),
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
updateTitleWithArtInvalidation(title, {
|
||||
title: movie.title,
|
||||
originalTitle: movie.original_title,
|
||||
overview: movie.overview,
|
||||
releaseDate: movie.release_date || null,
|
||||
posterPath: movie.poster_path,
|
||||
backdropPath: movie.backdrop_path,
|
||||
popularity: movie.popularity,
|
||||
voteAverage: movie.vote_average,
|
||||
voteCount: movie.vote_count,
|
||||
status: movie.status,
|
||||
contentRating: extractMovieContentRating(movie),
|
||||
lastFetchedAt: now,
|
||||
});
|
||||
upsertGenres(titleId, movie.genres ?? []);
|
||||
} else {
|
||||
const show = await getTvDetails(title.tmdbId);
|
||||
db.update(titles)
|
||||
.set({
|
||||
title: show.name,
|
||||
originalTitle: show.original_name,
|
||||
overview: show.overview,
|
||||
firstAirDate: show.first_air_date || null,
|
||||
posterPath: show.poster_path,
|
||||
backdropPath: show.backdrop_path,
|
||||
popularity: show.popularity,
|
||||
voteAverage: show.vote_average,
|
||||
voteCount: show.vote_count,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
updateTitleWithArtInvalidation(title, {
|
||||
title: show.name,
|
||||
originalTitle: show.original_name,
|
||||
overview: show.overview,
|
||||
firstAirDate: show.first_air_date || null,
|
||||
posterPath: show.poster_path,
|
||||
backdropPath: show.backdrop_path,
|
||||
popularity: show.popularity,
|
||||
voteAverage: show.vote_average,
|
||||
voteCount: show.vote_count,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||
lastFetchedAt: now,
|
||||
});
|
||||
upsertGenres(titleId, show.genres ?? []);
|
||||
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
|
||||
}
|
||||
|
||||
const updated = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
if (updated) {
|
||||
extractAndStoreColors(updated.id, updated.posterPath).catch((err) =>
|
||||
log.debug("Color extraction failed:", err),
|
||||
);
|
||||
syncTitleArt(
|
||||
updated.id,
|
||||
updated.posterPath,
|
||||
updated.backdropPath,
|
||||
updated.type as "movie" | "tv",
|
||||
).catch((err) => log.debug("Cache/thumbhash failed:", err));
|
||||
refreshTrailer(updated.id).catch((err) =>
|
||||
log.debug("Trailer enrichment failed:", err),
|
||||
);
|
||||
refreshCredits(updated.id).catch((err) =>
|
||||
log.debug("Credits enrichment failed:", err),
|
||||
);
|
||||
if (imageCacheEnabled()) {
|
||||
cacheImagesForTitle(updated.id).catch((err) =>
|
||||
log.debug("Image caching failed:", err),
|
||||
);
|
||||
if (updated.type === "tv") {
|
||||
cacheEpisodeStills(updated.id).catch((err) =>
|
||||
log.debug("Episode stills caching failed:", err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -381,6 +395,14 @@ export async function refreshTvChildren(
|
||||
|
||||
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 seasonRow = db
|
||||
.insert(seasons)
|
||||
@@ -406,6 +428,19 @@ export async function refreshTvChildren(
|
||||
.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),
|
||||
);
|
||||
|
||||
// Batch all episode upserts in a single transaction per season
|
||||
const eps = seasonData.episodes ?? [];
|
||||
if (eps.length > 0) {
|
||||
@@ -435,6 +470,38 @@ export async function refreshTvChildren(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -492,47 +559,123 @@ export async function refreshRecommendations(titleId: string) {
|
||||
|
||||
if (allItems.length === 0) return;
|
||||
|
||||
const uniqueTitles = new Map<
|
||||
number,
|
||||
{
|
||||
tmdbId: number;
|
||||
type: "movie" | "tv";
|
||||
title: string;
|
||||
originalTitle: string | null;
|
||||
overview: string | null;
|
||||
releaseDate: string | null;
|
||||
firstAirDate: string | null;
|
||||
posterPath: string | null;
|
||||
backdropPath: string | null;
|
||||
popularity: number | null;
|
||||
voteAverage: number | null;
|
||||
voteCount: number | null;
|
||||
}
|
||||
>();
|
||||
for (const item of allItems) {
|
||||
if (uniqueTitles.has(item.result.id)) continue;
|
||||
uniqueTitles.set(item.result.id, {
|
||||
tmdbId: item.result.id,
|
||||
type: item.type,
|
||||
title: item.result.title ?? item.result.name ?? "Unknown",
|
||||
originalTitle:
|
||||
item.result.original_title ?? item.result.original_name ?? null,
|
||||
overview: item.result.overview ?? null,
|
||||
releaseDate: item.result.release_date ?? null,
|
||||
firstAirDate: item.result.first_air_date ?? null,
|
||||
posterPath: item.result.poster_path ?? null,
|
||||
backdropPath: item.result.backdrop_path ?? null,
|
||||
popularity: item.result.popularity ?? null,
|
||||
voteAverage: item.result.vote_average ?? null,
|
||||
voteCount: item.result.vote_count ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Batch prefetch existing titles (1 query)
|
||||
const tmdbIds = [...new Set(allItems.map((item) => item.result.id))];
|
||||
const tmdbIds = [...uniqueTitles.keys()];
|
||||
const existingTitles = db
|
||||
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
||||
.select({
|
||||
id: titles.id,
|
||||
tmdbId: titles.tmdbId,
|
||||
posterPath: titles.posterPath,
|
||||
backdropPath: titles.backdropPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
backdropThumbHash: titles.backdropThumbHash,
|
||||
})
|
||||
.from(titles)
|
||||
.where(inArray(titles.tmdbId, tmdbIds))
|
||||
.all();
|
||||
const existingTitleMap = new Map(existingTitles.map((t) => [t.tmdbId, t]));
|
||||
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;
|
||||
for (const item of uniqueTitles.values()) {
|
||||
const existingTitle = existingTitleMap.get(item.tmdbId);
|
||||
if (existingTitle) {
|
||||
const posterPathChanged = existingTitle.posterPath !== item.posterPath;
|
||||
const backdropPathChanged =
|
||||
existingTitle.backdropPath !== item.backdropPath;
|
||||
|
||||
tx.update(titles)
|
||||
.set({
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
originalTitle: item.originalTitle,
|
||||
overview: item.overview,
|
||||
releaseDate: item.releaseDate,
|
||||
firstAirDate: item.firstAirDate,
|
||||
posterPath: item.posterPath,
|
||||
backdropPath: item.backdropPath,
|
||||
popularity: item.popularity,
|
||||
voteAverage: item.voteAverage,
|
||||
voteCount: item.voteCount,
|
||||
...(posterPathChanged
|
||||
? {
|
||||
posterThumbHash: null,
|
||||
colorPalette: null,
|
||||
}
|
||||
: {}),
|
||||
...(backdropPathChanged
|
||||
? {
|
||||
backdropThumbHash: null,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.where(eq(titles.id, existingTitle.id))
|
||||
.run();
|
||||
continue;
|
||||
}
|
||||
|
||||
insertedTmdbIds.add(item.tmdbId);
|
||||
const row = tx
|
||||
.insert(titles)
|
||||
.values({
|
||||
tmdbId: r.id,
|
||||
tmdbId: item.tmdbId,
|
||||
type: item.type,
|
||||
title: r.title ?? r.name ?? "Unknown",
|
||||
originalTitle: r.original_title ?? r.original_name,
|
||||
overview: r.overview,
|
||||
releaseDate: r.release_date,
|
||||
firstAirDate: r.first_air_date,
|
||||
posterPath: r.poster_path,
|
||||
backdropPath: r.backdrop_path,
|
||||
popularity: r.popularity,
|
||||
voteAverage: r.vote_average,
|
||||
voteCount: r.vote_count,
|
||||
title: item.title,
|
||||
originalTitle: item.originalTitle,
|
||||
overview: item.overview,
|
||||
releaseDate: item.releaseDate,
|
||||
firstAirDate: item.firstAirDate,
|
||||
posterPath: item.posterPath,
|
||||
backdropPath: item.backdropPath,
|
||||
popularity: item.popularity,
|
||||
voteAverage: item.voteAverage,
|
||||
voteCount: item.voteCount,
|
||||
lastFetchedAt: null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.get();
|
||||
if (row) titleIdMap.set(r.id, row.id);
|
||||
if (row) titleIdMap.set(item.tmdbId, row.id);
|
||||
}
|
||||
|
||||
// One fallback query for any that conflicted
|
||||
@@ -580,6 +723,31 @@ export async function refreshRecommendations(titleId: string) {
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
// Fire-and-forget thumbhash generation for recommendation titles missing one
|
||||
const recTitleIds = [
|
||||
...new Set(
|
||||
allItems.map((i) => titleIdMap.get(i.result.id)).filter(Boolean),
|
||||
),
|
||||
] as string[];
|
||||
if (recTitleIds.length > 0) {
|
||||
const needingHash = db
|
||||
.select({ id: titles.id, posterPath: titles.posterPath })
|
||||
.from(titles)
|
||||
.where(
|
||||
and(
|
||||
inArray(titles.id, recTitleIds),
|
||||
isNotNull(titles.posterPath),
|
||||
sql`${titles.posterThumbHash} IS NULL`,
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const t of needingHash) {
|
||||
generateTitlePosterThumbHash(t.id, t.posterPath).catch((err) =>
|
||||
log.debug("Recommendation poster thumbhash failed:", err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch seasons from the DB, building the Season[] structure. */
|
||||
@@ -610,6 +778,7 @@ function fetchSeasonsFromDb(titleId: string): Season[] {
|
||||
name: ep.name,
|
||||
overview: ep.overview,
|
||||
stillPath: tmdbImageUrl(ep.stillPath, "stills"),
|
||||
stillThumbHash: ep.stillThumbHash,
|
||||
airDate: ep.airDate,
|
||||
runtimeMinutes: ep.runtimeMinutes,
|
||||
});
|
||||
@@ -639,17 +808,14 @@ export async function ensureTvHydrated(
|
||||
if (!title.lastFetchedAt) {
|
||||
try {
|
||||
const show = await getTvDetails(tmdbId);
|
||||
db.update(titles)
|
||||
.set({
|
||||
overview: show.overview,
|
||||
posterPath: show.poster_path,
|
||||
backdropPath: show.backdrop_path,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
updateTitleWithArtInvalidation(title, {
|
||||
overview: show.overview,
|
||||
posterPath: show.poster_path,
|
||||
backdropPath: show.backdrop_path,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
lastFetchedAt: new Date(),
|
||||
});
|
||||
upsertGenres(titleId, show.genres ?? []);
|
||||
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
||||
} catch (err) {
|
||||
@@ -686,7 +852,7 @@ async function ensureEnriched(
|
||||
title: typeof titles.$inferSelect,
|
||||
existing: { hasCast: boolean; hasAvailability: boolean },
|
||||
): Promise<boolean> {
|
||||
const tasks: Promise<void>[] = [];
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (!existing.hasCast) {
|
||||
tasks.push(
|
||||
@@ -720,12 +886,20 @@ async function ensureEnriched(
|
||||
);
|
||||
}
|
||||
|
||||
if (!title.colorPalette && title.posterPath) {
|
||||
const needsTitleArtSync =
|
||||
(!title.posterPath && (!!title.posterThumbHash || !!title.colorPalette)) ||
|
||||
(!!title.posterPath && (!title.posterThumbHash || !title.colorPalette)) ||
|
||||
(!title.backdropPath && !!title.backdropThumbHash) ||
|
||||
(!!title.backdropPath && !title.backdropThumbHash);
|
||||
|
||||
if (needsTitleArtSync) {
|
||||
tasks.push(
|
||||
extractAndStoreColors(titleId, title.posterPath).then(
|
||||
() => {},
|
||||
(err) => log.debug("Color enrichment failed:", err),
|
||||
),
|
||||
syncTitleArt(
|
||||
titleId,
|
||||
title.posterPath,
|
||||
title.backdropPath,
|
||||
title.type as "movie" | "tv",
|
||||
).catch((err) => log.debug("Title art enrichment failed:", err)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -786,23 +960,20 @@ export async function getOrFetchTitle(id: string): Promise<{
|
||||
if (title.type === "movie" && !title.lastFetchedAt) {
|
||||
try {
|
||||
const movie = await getMovieDetails(title.tmdbId);
|
||||
db.update(titles)
|
||||
.set({
|
||||
title: movie.title,
|
||||
originalTitle: movie.original_title,
|
||||
overview: movie.overview,
|
||||
releaseDate: movie.release_date || null,
|
||||
posterPath: movie.poster_path,
|
||||
backdropPath: movie.backdrop_path,
|
||||
popularity: movie.popularity,
|
||||
voteAverage: movie.vote_average,
|
||||
voteCount: movie.vote_count,
|
||||
status: movie.status,
|
||||
contentRating: extractMovieContentRating(movie),
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.where(eq(titles.id, id))
|
||||
.run();
|
||||
updateTitleWithArtInvalidation(title, {
|
||||
title: movie.title,
|
||||
originalTitle: movie.original_title,
|
||||
overview: movie.overview,
|
||||
releaseDate: movie.release_date || null,
|
||||
posterPath: movie.poster_path,
|
||||
backdropPath: movie.backdrop_path,
|
||||
popularity: movie.popularity,
|
||||
voteAverage: movie.vote_average,
|
||||
voteCount: movie.vote_count,
|
||||
status: movie.status,
|
||||
contentRating: extractMovieContentRating(movie),
|
||||
lastFetchedAt: new Date(),
|
||||
});
|
||||
upsertGenres(id, movie.genres ?? []);
|
||||
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
||||
} catch (err) {
|
||||
@@ -849,7 +1020,9 @@ export async function getOrFetchTitle(id: string): Promise<{
|
||||
releaseDate: title.releaseDate,
|
||||
firstAirDate: title.firstAirDate,
|
||||
posterPath: tmdbImageUrl(title.posterPath, "posters"),
|
||||
posterThumbHash: title.posterThumbHash,
|
||||
backdropPath: tmdbImageUrl(title.backdropPath, "backdrops"),
|
||||
backdropThumbHash: title.backdropThumbHash,
|
||||
popularity: title.popularity,
|
||||
voteAverage: title.voteAverage,
|
||||
voteCount: title.voteCount,
|
||||
@@ -916,6 +1089,92 @@ export async function refreshTrailer(titleId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function generateMissingTvChildThumbHashes(titleId: string) {
|
||||
const titleSeasons = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
|
||||
const hashTasks: Promise<unknown>[] = [];
|
||||
|
||||
for (const s of titleSeasons) {
|
||||
if (s.posterPath && !s.posterThumbHash) {
|
||||
hashTasks.push(generateSeasonThumbHash(s.id, s.posterPath));
|
||||
}
|
||||
}
|
||||
|
||||
const seasonIds = titleSeasons.map((s) => s.id);
|
||||
if (seasonIds.length > 0) {
|
||||
const epsNeedingHash = db
|
||||
.select({
|
||||
id: episodes.id,
|
||||
stillPath: episodes.stillPath,
|
||||
})
|
||||
.from(episodes)
|
||||
.where(
|
||||
and(
|
||||
inArray(episodes.seasonId, seasonIds),
|
||||
isNotNull(episodes.stillPath),
|
||||
sql`${episodes.stillThumbHash} IS NULL`,
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const ep of epsNeedingHash) {
|
||||
hashTasks.push(generateEpisodeThumbHash(ep.id, ep.stillPath));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(hashTasks);
|
||||
}
|
||||
|
||||
export async function syncTvChildArt(
|
||||
titleId: string,
|
||||
options?: { warmCache?: boolean },
|
||||
) {
|
||||
if (options?.warmCache !== false && imageCacheEnabled()) {
|
||||
await Promise.all([
|
||||
cacheImagesForTitle(titleId),
|
||||
cacheEpisodeStills(titleId),
|
||||
]);
|
||||
}
|
||||
|
||||
await generateMissingTvChildThumbHashes(titleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm the image cache for a title, then derive poster colors and thumbhashes.
|
||||
* Sequencing avoids duplicate TMDB downloads on cold caches.
|
||||
*/
|
||||
async function syncTitleArt(
|
||||
titleId: string,
|
||||
posterPath: string | null | undefined,
|
||||
backdropPath: string | null | undefined,
|
||||
type: "movie" | "tv",
|
||||
) {
|
||||
if (imageCacheEnabled()) {
|
||||
await cacheImagesForTitle(titleId);
|
||||
if (type === "tv") {
|
||||
await cacheEpisodeStills(titleId);
|
||||
}
|
||||
}
|
||||
|
||||
const posterBuffer = posterPath
|
||||
? await loadImageBuffer(posterPath, "posters")
|
||||
: undefined;
|
||||
|
||||
// Title poster colors + thumbhash, then backdrop thumbhash
|
||||
await Promise.all([
|
||||
extractAndStoreColors(titleId, posterPath ?? null, posterBuffer),
|
||||
generateTitlePosterThumbHash(titleId, posterPath ?? null, posterBuffer),
|
||||
generateTitleBackdropThumbHash(titleId, backdropPath ?? null),
|
||||
]);
|
||||
|
||||
if (type === "tv") {
|
||||
await syncTvChildArt(titleId, { warmCache: false });
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -5,9 +5,30 @@ import { persons, titleCast, titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
|
||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||
import { generatePersonThumbHash } from "./thumbhash";
|
||||
|
||||
const log = createLogger("person");
|
||||
|
||||
async function ensureProfileThumbHash(
|
||||
person: Pick<
|
||||
typeof persons.$inferSelect,
|
||||
"id" | "profilePath" | "profileThumbHash"
|
||||
>,
|
||||
) {
|
||||
if (!person.profilePath) {
|
||||
if (person.profileThumbHash) {
|
||||
await generatePersonThumbHash(person.id, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (person.profileThumbHash) {
|
||||
return person.profileThumbHash;
|
||||
}
|
||||
|
||||
return (await generatePersonThumbHash(person.id, person.profilePath)) ?? null;
|
||||
}
|
||||
|
||||
export async function getOrFetchPerson(
|
||||
personId: string,
|
||||
): Promise<ResolvedPerson | null> {
|
||||
@@ -38,6 +59,20 @@ export async function getOrFetchPerson(
|
||||
.where(eq(persons.id, personId))
|
||||
.run();
|
||||
|
||||
const newProfilePath = details.profile_path ?? null;
|
||||
const pathChanged = newProfilePath !== person.profilePath;
|
||||
const profileThumbHash = pathChanged
|
||||
? await ensureProfileThumbHash({
|
||||
id: personId,
|
||||
profilePath: newProfilePath,
|
||||
profileThumbHash: null,
|
||||
})
|
||||
: await ensureProfileThumbHash({
|
||||
id: personId,
|
||||
profilePath: newProfilePath,
|
||||
profileThumbHash: person.profileThumbHash,
|
||||
});
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
tmdbId: person.tmdbId,
|
||||
@@ -47,6 +82,7 @@ export async function getOrFetchPerson(
|
||||
deathday: details.deathday ?? null,
|
||||
placeOfBirth: details.place_of_birth ?? null,
|
||||
profilePath: tmdbImageUrl(details.profile_path ?? null, "profiles"),
|
||||
profileThumbHash,
|
||||
knownForDepartment: details.known_for_department ?? null,
|
||||
imdbId: details.imdb_id ?? null,
|
||||
};
|
||||
@@ -55,6 +91,8 @@ export async function getOrFetchPerson(
|
||||
}
|
||||
}
|
||||
|
||||
const profileThumbHash = await ensureProfileThumbHash(person);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
tmdbId: person.tmdbId,
|
||||
@@ -64,6 +102,7 @@ export async function getOrFetchPerson(
|
||||
deathday: person.deathday,
|
||||
placeOfBirth: person.placeOfBirth,
|
||||
profilePath: tmdbImageUrl(person.profilePath, "profiles"),
|
||||
profileThumbHash,
|
||||
knownForDepartment: person.knownForDepartment,
|
||||
imdbId: person.imdbId,
|
||||
};
|
||||
@@ -108,6 +147,8 @@ export async function getOrFetchPersonByTmdbId(
|
||||
row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
||||
if (!person) return null;
|
||||
|
||||
const profileThumbHash = await ensureProfileThumbHash(person);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
tmdbId: person.tmdbId,
|
||||
@@ -117,6 +158,7 @@ export async function getOrFetchPersonByTmdbId(
|
||||
deathday: person.deathday,
|
||||
placeOfBirth: person.placeOfBirth,
|
||||
profilePath: tmdbImageUrl(person.profilePath, "profiles"),
|
||||
profileThumbHash,
|
||||
knownForDepartment: person.knownForDepartment,
|
||||
imdbId: person.imdbId,
|
||||
};
|
||||
@@ -134,6 +176,7 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
|
||||
type: titles.type,
|
||||
title: titles.title,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
releaseDate: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
voteAverage: titles.voteAverage,
|
||||
@@ -152,6 +195,7 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
|
||||
type: r.type as "movie" | "tv",
|
||||
title: r.title,
|
||||
posterPath: tmdbImageUrl(r.posterPath, "posters"),
|
||||
posterThumbHash: r.posterThumbHash,
|
||||
releaseDate: r.releaseDate,
|
||||
firstAirDate: r.firstAirDate,
|
||||
voteAverage: r.voteAverage,
|
||||
@@ -251,6 +295,7 @@ export async function fetchFullFilmography(
|
||||
type: c.media_type as "movie" | "tv",
|
||||
title: c.title ?? c.name ?? "Unknown",
|
||||
posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
|
||||
posterThumbHash: null,
|
||||
releaseDate: c.release_date ?? null,
|
||||
firstAirDate: c.first_air_date ?? null,
|
||||
voteAverage: c.vote_average,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import path from "node:path";
|
||||
import { db } from "@sofa/db/client";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes, persons, seasons, titles } from "@sofa/db/schema";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
import type { ImageCategory } from "@sofa/tmdb/image";
|
||||
import sharp from "sharp";
|
||||
import { rgbaToThumbHash } from "thumbhash";
|
||||
import { loadImageBuffer } from "./image-cache";
|
||||
|
||||
const log = createLogger("thumbhash");
|
||||
|
||||
/**
|
||||
* Generate a ThumbHash string from an image path.
|
||||
* Returns a base64-encoded ThumbHash, or null on failure.
|
||||
*/
|
||||
export async function generateThumbHash(
|
||||
imagePath: string,
|
||||
category: ImageCategory,
|
||||
sourceBuffer?: Buffer | null,
|
||||
): Promise<string | null> {
|
||||
const filename = path.basename(imagePath);
|
||||
const source =
|
||||
sourceBuffer === undefined
|
||||
? await loadImageBuffer(imagePath, category)
|
||||
: sourceBuffer;
|
||||
if (!source) return null;
|
||||
|
||||
try {
|
||||
const { data, info } = await sharp(source)
|
||||
.resize(100, 100, { fit: "inside" })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const hash = rgbaToThumbHash(info.width, info.height, data);
|
||||
return Buffer.from(hash).toString("base64");
|
||||
} catch (err) {
|
||||
log.error(`Failed to generate thumbhash for ${category}/${filename}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateTitlePosterThumbHash(
|
||||
titleId: string,
|
||||
posterPath: string | null,
|
||||
sourceBuffer?: Buffer | null,
|
||||
): Promise<string | null> {
|
||||
const hash = posterPath
|
||||
? await generateThumbHash(posterPath, "posters", sourceBuffer)
|
||||
: null;
|
||||
db.update(titles)
|
||||
.set({ posterThumbHash: hash })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
export async function generateTitleBackdropThumbHash(
|
||||
titleId: string,
|
||||
backdropPath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = backdropPath
|
||||
? await generateThumbHash(backdropPath, "backdrops")
|
||||
: null;
|
||||
db.update(titles)
|
||||
.set({ backdropThumbHash: hash })
|
||||
.where(eq(titles.id, titleId))
|
||||
.run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
export async function generateSeasonThumbHash(
|
||||
seasonId: string,
|
||||
posterPath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = posterPath
|
||||
? await generateThumbHash(posterPath, "posters")
|
||||
: null;
|
||||
db.update(seasons)
|
||||
.set({ posterThumbHash: hash })
|
||||
.where(eq(seasons.id, seasonId))
|
||||
.run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
export async function generateEpisodeThumbHash(
|
||||
episodeId: string,
|
||||
stillPath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = stillPath ? await generateThumbHash(stillPath, "stills") : null;
|
||||
db.update(episodes)
|
||||
.set({ stillThumbHash: hash })
|
||||
.where(eq(episodes.id, episodeId))
|
||||
.run();
|
||||
return hash;
|
||||
}
|
||||
|
||||
export async function generatePersonThumbHash(
|
||||
personId: string,
|
||||
profilePath: string | null,
|
||||
): Promise<string | null> {
|
||||
const hash = profilePath
|
||||
? await generateThumbHash(profilePath, "profiles")
|
||||
: null;
|
||||
db.update(persons)
|
||||
.set({ profileThumbHash: hash })
|
||||
.where(eq(persons.id, personId))
|
||||
.run();
|
||||
return hash;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { loadImageBuffer } from "../src/image-cache";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.IMAGE_CACHE_ENABLED = "false";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
delete process.env.IMAGE_CACHE_ENABLED;
|
||||
});
|
||||
|
||||
describe("loadImageBuffer", () => {
|
||||
test("uses category-specific TMDB sizes when cache is disabled", async () => {
|
||||
const urls: string[] = [];
|
||||
globalThis.fetch = mock(async (input: string | URL | Request) => {
|
||||
urls.push(String(input));
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await loadImageBuffer("/poster.jpg", "posters");
|
||||
await loadImageBuffer("/profile.jpg", "profiles");
|
||||
|
||||
expect(urls).toEqual([
|
||||
"https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||
"https://image.tmdb.org/t/p/w185/profile.jpg",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes, seasons, titles } from "@sofa/db/schema";
|
||||
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
interface MockSeasonEpisode {
|
||||
episode_number: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
still_path: string | null;
|
||||
air_date: string | null;
|
||||
runtime: number | null;
|
||||
}
|
||||
|
||||
interface MockSeasonDetails {
|
||||
season_number: number;
|
||||
name: string | null;
|
||||
overview: string | null;
|
||||
poster_path: string | null;
|
||||
air_date: string | null;
|
||||
episodes: MockSeasonEpisode[];
|
||||
}
|
||||
|
||||
let seasonDetails: MockSeasonDetails = {
|
||||
season_number: 1,
|
||||
name: "Season 1",
|
||||
overview: null,
|
||||
poster_path: "/new-season.png",
|
||||
air_date: null,
|
||||
episodes: [
|
||||
{
|
||||
episode_number: 1,
|
||||
name: "Episode 1",
|
||||
overview: null,
|
||||
still_path: "/new-still.png",
|
||||
air_date: null,
|
||||
runtime: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mock.module("@sofa/tmdb/client", () => ({
|
||||
getMovieDetails: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
getRecommendations: async () => ({ results: [] }),
|
||||
getSimilar: async () => ({ results: [] }),
|
||||
getTvDetails: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
getTvSeasonDetails: async () => seasonDetails,
|
||||
getVideos: async () => ({ results: [] }),
|
||||
}));
|
||||
|
||||
import {
|
||||
refreshTvChildren,
|
||||
updateTitleWithArtInvalidation,
|
||||
} from "../src/metadata";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
seasonDetails = {
|
||||
season_number: 1,
|
||||
name: "Season 1",
|
||||
overview: null,
|
||||
poster_path: "/new-season.png",
|
||||
air_date: null,
|
||||
episodes: [
|
||||
{
|
||||
episode_number: 1,
|
||||
name: "Episode 1",
|
||||
overview: null,
|
||||
still_path: "/new-still.png",
|
||||
air_date: null,
|
||||
runtime: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
describe("refreshTvChildren", () => {
|
||||
test("clears stale season and episode thumbhashes when artwork paths change", async () => {
|
||||
insertTitle({ id: "tv-1", tmdbId: 10, type: "tv", title: "Show" });
|
||||
testDb
|
||||
.insert(seasons)
|
||||
.values({
|
||||
id: "season-1",
|
||||
titleId: "tv-1",
|
||||
seasonNumber: 1,
|
||||
posterPath: "/old-season.png",
|
||||
posterThumbHash: "stale-season-hash",
|
||||
})
|
||||
.run();
|
||||
testDb
|
||||
.insert(episodes)
|
||||
.values({
|
||||
id: "episode-1",
|
||||
seasonId: "season-1",
|
||||
episodeNumber: 1,
|
||||
stillPath: "/old-still.png",
|
||||
stillThumbHash: "stale-episode-hash",
|
||||
})
|
||||
.run();
|
||||
|
||||
await refreshTvChildren("tv-1", 10, 1);
|
||||
|
||||
const season = testDb
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.id, "season-1"))
|
||||
.get();
|
||||
const episode = testDb
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, "episode-1"))
|
||||
.get();
|
||||
|
||||
expect(season?.posterPath).toBe("/new-season.png");
|
||||
expect(season?.posterThumbHash).toBeNull();
|
||||
expect(episode?.stillPath).toBe("/new-still.png");
|
||||
expect(episode?.stillThumbHash).toBeNull();
|
||||
});
|
||||
|
||||
test("clears an episode thumbhash when the still disappears", async () => {
|
||||
insertTitle({ id: "tv-1", tmdbId: 10, type: "tv", title: "Show" });
|
||||
testDb
|
||||
.insert(seasons)
|
||||
.values({
|
||||
id: "season-1",
|
||||
titleId: "tv-1",
|
||||
seasonNumber: 1,
|
||||
posterPath: "/season.png",
|
||||
})
|
||||
.run();
|
||||
testDb
|
||||
.insert(episodes)
|
||||
.values({
|
||||
id: "episode-1",
|
||||
seasonId: "season-1",
|
||||
episodeNumber: 1,
|
||||
stillPath: "/old-still.png",
|
||||
stillThumbHash: "stale-episode-hash",
|
||||
})
|
||||
.run();
|
||||
|
||||
seasonDetails = {
|
||||
...seasonDetails,
|
||||
poster_path: "/season.png",
|
||||
episodes: [
|
||||
{
|
||||
episode_number: 1,
|
||||
name: "Episode 1",
|
||||
overview: null,
|
||||
still_path: null,
|
||||
air_date: null,
|
||||
runtime: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await refreshTvChildren("tv-1", 10, 1);
|
||||
|
||||
const episode = testDb
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, "episode-1"))
|
||||
.get();
|
||||
|
||||
expect(episode?.stillPath).toBeNull();
|
||||
expect(episode?.stillThumbHash).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTitleWithArtInvalidation", () => {
|
||||
test("clears stale title hashes and palette when artwork changes", () => {
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({
|
||||
id: "movie-1",
|
||||
tmdbId: 20,
|
||||
type: "movie",
|
||||
title: "Movie",
|
||||
posterPath: "/old-poster.png",
|
||||
posterThumbHash: "stale-poster-hash",
|
||||
backdropPath: "/old-backdrop.png",
|
||||
backdropThumbHash: "stale-backdrop-hash",
|
||||
colorPalette: JSON.stringify({ vibrant: "#fff" }),
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
|
||||
const existing = testDb
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, "movie-1"))
|
||||
.get();
|
||||
|
||||
expect(existing).not.toBeUndefined();
|
||||
if (!existing) {
|
||||
throw new Error("Expected seeded title row");
|
||||
}
|
||||
|
||||
updateTitleWithArtInvalidation(existing, {
|
||||
posterPath: "/new-poster.png",
|
||||
backdropPath: "/new-backdrop.png",
|
||||
});
|
||||
|
||||
const title = testDb
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, "movie-1"))
|
||||
.get();
|
||||
|
||||
expect(title?.posterPath).toBe("/new-poster.png");
|
||||
expect(title?.backdropPath).toBe("/new-backdrop.png");
|
||||
expect(title?.posterThumbHash).toBeNull();
|
||||
expect(title?.backdropThumbHash).toBeNull();
|
||||
expect(title?.colorPalette).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { persons } from "@sofa/db/schema";
|
||||
import { clearAllTables, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
const TINY_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
let nextBuffer: Buffer | null = TINY_PNG;
|
||||
let nextPersonDetails = {
|
||||
id: 100,
|
||||
name: "Updated Person",
|
||||
biography: "Bio",
|
||||
birthday: null,
|
||||
deathday: null,
|
||||
place_of_birth: null,
|
||||
profile_path: "/new-profile.png",
|
||||
known_for_department: "Acting",
|
||||
popularity: 10,
|
||||
imdb_id: null,
|
||||
};
|
||||
|
||||
mock.module("../src/image-cache", () => ({
|
||||
loadImageBuffer: async () => nextBuffer,
|
||||
}));
|
||||
|
||||
mock.module("@sofa/tmdb/client", () => ({
|
||||
getPersonDetails: async () => nextPersonDetails,
|
||||
getPersonCombinedCredits: async () => ({ cast: [] }),
|
||||
}));
|
||||
|
||||
import { getOrFetchPerson } from "../src/person";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
nextBuffer = TINY_PNG;
|
||||
nextPersonDetails = {
|
||||
id: 100,
|
||||
name: "Updated Person",
|
||||
biography: "Bio",
|
||||
birthday: null,
|
||||
deathday: null,
|
||||
place_of_birth: null,
|
||||
profile_path: "/new-profile.png",
|
||||
known_for_department: "Acting",
|
||||
popularity: 10,
|
||||
imdb_id: null,
|
||||
};
|
||||
});
|
||||
|
||||
describe("getOrFetchPerson", () => {
|
||||
test("backfills a missing profile thumbhash for an already-fetched person", async () => {
|
||||
testDb
|
||||
.insert(persons)
|
||||
.values({
|
||||
id: "p1",
|
||||
tmdbId: 100,
|
||||
name: "Existing Person",
|
||||
profilePath: "/profile.png",
|
||||
profileThumbHash: null,
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
|
||||
const person = await getOrFetchPerson("p1");
|
||||
const stored = testDb
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, "p1"))
|
||||
.get();
|
||||
|
||||
expect(person?.profileThumbHash).toBeString();
|
||||
expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
|
||||
});
|
||||
|
||||
test("replaces a stale profile thumbhash when shell hydration changes the image path", async () => {
|
||||
testDb
|
||||
.insert(persons)
|
||||
.values({
|
||||
id: "p1",
|
||||
tmdbId: 100,
|
||||
name: "Shell Person",
|
||||
profilePath: "/old-profile.png",
|
||||
profileThumbHash: "stale-hash",
|
||||
lastFetchedAt: null,
|
||||
})
|
||||
.run();
|
||||
|
||||
const person = await getOrFetchPerson("p1");
|
||||
const stored = testDb
|
||||
.select()
|
||||
.from(persons)
|
||||
.where(eq(persons.id, "p1"))
|
||||
.get();
|
||||
|
||||
expect(stored?.profilePath).toBe("/new-profile.png");
|
||||
expect(person?.profileThumbHash).toBeString();
|
||||
expect(person?.profileThumbHash).not.toBe("stale-hash");
|
||||
expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { eq } from "@sofa/db/helpers";
|
||||
import { episodes, seasons, titles } from "@sofa/db/schema";
|
||||
import { clearAllTables, testDb } from "@sofa/db/test-utils";
|
||||
|
||||
const TINY_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
let nextBuffer: Buffer | null = TINY_PNG;
|
||||
|
||||
mock.module("../src/image-cache", () => ({
|
||||
loadImageBuffer: async () => nextBuffer,
|
||||
}));
|
||||
|
||||
import { generateEpisodeThumbHash, generateThumbHash } from "../src/thumbhash";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
nextBuffer = TINY_PNG;
|
||||
});
|
||||
|
||||
describe("thumbhash generation", () => {
|
||||
test("generates a thumbhash from a loaded image buffer", async () => {
|
||||
const hash = await generateThumbHash("/poster.png", "posters");
|
||||
expect(hash).toBeString();
|
||||
expect(hash).not.toBe("");
|
||||
});
|
||||
|
||||
test("clears a stale episode thumbhash when regeneration fails", async () => {
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({ id: "tv-1", tmdbId: 100, type: "tv", title: "Show" })
|
||||
.run();
|
||||
testDb
|
||||
.insert(seasons)
|
||||
.values({ id: "season-1", titleId: "tv-1", seasonNumber: 1 })
|
||||
.run();
|
||||
testDb
|
||||
.insert(episodes)
|
||||
.values({
|
||||
id: "ep-1",
|
||||
seasonId: "season-1",
|
||||
episodeNumber: 1,
|
||||
stillPath: "/old-still.png",
|
||||
stillThumbHash: "stale-hash",
|
||||
})
|
||||
.run();
|
||||
|
||||
nextBuffer = null;
|
||||
const hash = await generateEpisodeThumbHash("ep-1", "/new-still.png");
|
||||
|
||||
const episode = testDb
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, "ep-1"))
|
||||
.get();
|
||||
|
||||
expect(hash).toBeNull();
|
||||
expect(episode?.stillThumbHash).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE `episodes` ADD `stillThumbHash` text;--> statement-breakpoint
|
||||
ALTER TABLE `persons` ADD `profileThumbHash` text;--> statement-breakpoint
|
||||
ALTER TABLE `seasons` ADD `posterThumbHash` text;--> statement-breakpoint
|
||||
ALTER TABLE `titles` ADD `posterThumbHash` text;--> statement-breakpoint
|
||||
ALTER TABLE `titles` ADD `backdropThumbHash` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -110,7 +110,9 @@ export const titles = sqliteTable(
|
||||
releaseDate: text("releaseDate"),
|
||||
firstAirDate: text("firstAirDate"),
|
||||
posterPath: text("posterPath"),
|
||||
posterThumbHash: text("posterThumbHash"),
|
||||
backdropPath: text("backdropPath"),
|
||||
backdropThumbHash: text("backdropThumbHash"),
|
||||
popularity: real("popularity"),
|
||||
voteAverage: real("voteAverage"),
|
||||
voteCount: int("voteCount"),
|
||||
@@ -144,6 +146,7 @@ export const seasons = sqliteTable(
|
||||
name: text("name"),
|
||||
overview: text("overview"),
|
||||
posterPath: text("posterPath"),
|
||||
posterThumbHash: text("posterThumbHash"),
|
||||
airDate: text("airDate"),
|
||||
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
||||
},
|
||||
@@ -166,6 +169,7 @@ export const episodes = sqliteTable(
|
||||
name: text("name"),
|
||||
overview: text("overview"),
|
||||
stillPath: text("stillPath"),
|
||||
stillThumbHash: text("stillThumbHash"),
|
||||
airDate: text("airDate"),
|
||||
runtimeMinutes: int("runtimeMinutes"),
|
||||
},
|
||||
@@ -339,6 +343,7 @@ export const persons = sqliteTable(
|
||||
deathday: text("deathday"),
|
||||
placeOfBirth: text("placeOfBirth"),
|
||||
profilePath: text("profilePath"),
|
||||
profileThumbHash: text("profileThumbHash"),
|
||||
knownForDepartment: text("knownForDepartment"),
|
||||
popularity: real("popularity"),
|
||||
imdbId: text("imdbId"),
|
||||
|
||||
@@ -8,7 +8,7 @@ export type ImageCategory =
|
||||
const IMAGE_BASE_URL =
|
||||
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
|
||||
|
||||
const CATEGORY_SIZES: Record<ImageCategory, string> = {
|
||||
export const IMAGE_CATEGORY_SIZES: Record<ImageCategory, string> = {
|
||||
posters: "w500",
|
||||
backdrops: "w1280",
|
||||
stills: "w1280",
|
||||
@@ -16,6 +16,16 @@ const CATEGORY_SIZES: Record<ImageCategory, string> = {
|
||||
profiles: "w185",
|
||||
};
|
||||
|
||||
export function tmdbCdnImageUrl(
|
||||
path: string | null,
|
||||
category: ImageCategory,
|
||||
sizeOverride?: string,
|
||||
) {
|
||||
if (!path) return null;
|
||||
const size = sizeOverride ?? IMAGE_CATEGORY_SIZES[category];
|
||||
return `${IMAGE_BASE_URL}/${size}${path}`;
|
||||
}
|
||||
|
||||
export function tmdbImageUrl(
|
||||
path: string | null,
|
||||
category: ImageCategory,
|
||||
@@ -23,10 +33,8 @@ export function tmdbImageUrl(
|
||||
) {
|
||||
if (!path) return null;
|
||||
|
||||
const size = sizeOverride ?? CATEGORY_SIZES[category];
|
||||
|
||||
if (process.env.IMAGE_CACHE_ENABLED === "false") {
|
||||
return `${IMAGE_BASE_URL}/${size}${path}`;
|
||||
return tmdbCdnImageUrl(path, category, sizeOverride);
|
||||
}
|
||||
|
||||
const filename = path.startsWith("/") ? path.slice(1) : path;
|
||||
|
||||
Reference in new issue
Block a user