Files
sofa/lib/services/image-cache.ts
T
jakeandClaude Opus 4.6 ed6e061436 Introduce DATA_DIR env var for consistent data path configuration
Replace separate DATABASE_URL and IMAGE_CACHE_DIR env vars with a
single DATA_DIR root (default: ./data, Docker: /data). DATABASE_URL
and CACHE_DIR are derived from it but can still be overridden
individually.

Also:
- Pin pnpm to @10 for reproducible Docker builds
- Add --link to COPY instructions for better BuildKit cache reuse
- Add syntax directive for BuildKit features
- Simplify Dockerfile mkdir to just /data (ensureImageDirs handles subdirs)
- Remove redundant DATABASE_URL from docker-compose.yml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:26:08 -05:00

213 lines
5.5 KiB
TypeScript

import { existsSync, mkdirSync } from "node:fs";
import { readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema";
export type ImageCategory = "posters" | "backdrops" | "stills" | "logos";
const CATEGORY_SIZES: Record<ImageCategory, string> = {
posters: "w500",
backdrops: "w1280",
stills: "w1280",
logos: "w92",
};
const IMAGE_BASE_URL =
process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
const DATA_DIR = process.env.DATA_DIR || "./data";
const CACHE_DIR = process.env.CACHE_DIR
? path.join(process.env.CACHE_DIR, "images")
: path.join(DATA_DIR, "images");
export function imageCacheEnabled(): boolean {
return process.env.IMAGE_CACHE_ENABLED !== "false";
}
export function ensureImageDirs() {
for (const category of Object.keys(CATEGORY_SIZES)) {
const dir = path.join(CACHE_DIR, category);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
}
export function isImageCached(
category: ImageCategory,
filename: string,
): boolean {
return existsSync(getLocalImagePath(category, filename));
}
export function getLocalImagePath(
category: ImageCategory,
filename: string,
): string {
return path.join(CACHE_DIR, category, path.basename(filename));
}
export async function readCachedImage(
category: ImageCategory,
filename: string,
): Promise<Buffer | null> {
const filePath = getLocalImagePath(category, filename);
if (!existsSync(filePath)) return null;
return readFile(filePath);
}
export async function downloadAndCacheImage(
tmdbPath: string,
category: ImageCategory,
): Promise<Buffer | null> {
const size = CATEGORY_SIZES[category];
const url = `${IMAGE_BASE_URL}/${size}${tmdbPath}`;
const res = await fetch(url);
if (!res.ok) return null;
const buffer = Buffer.from(await res.arrayBuffer());
const filename = path.basename(tmdbPath);
const finalPath = getLocalImagePath(category, filename);
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
try {
await writeFile(tmpPath, buffer);
await rename(tmpPath, finalPath);
} catch {
// Best-effort cleanup
}
return buffer;
}
export async function fetchAndMaybeCache(
tmdbPath: string,
category: ImageCategory,
): Promise<{ buffer: Buffer; contentType: string } | null> {
// Try local first
const filename = path.basename(tmdbPath);
const cached = await readCachedImage(category, filename);
if (cached) {
const ext = path.extname(filename).toLowerCase();
const contentType =
ext === ".png"
? "image/png"
: ext === ".webp"
? "image/webp"
: "image/jpeg";
return { buffer: cached, contentType };
}
// Fetch from TMDB
const size = CATEGORY_SIZES[category];
const url = `${IMAGE_BASE_URL}/${size}${tmdbPath}`;
const res = await fetch(url);
if (!res.ok) return null;
const buffer = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type") || "image/jpeg";
// Fire-and-forget save to disk
const finalPath = getLocalImagePath(category, filename);
const tmpPath = `${finalPath}.tmp.${Date.now()}`;
writeFile(tmpPath, buffer)
.then(() => rename(tmpPath, finalPath))
.catch(() => {});
return { buffer, contentType };
}
export async function cacheImagesForTitle(titleId: string) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (!title) return;
const tasks: Promise<unknown>[] = [];
if (
title.posterPath &&
!isImageCached("posters", path.basename(title.posterPath))
) {
tasks.push(downloadAndCacheImage(title.posterPath, "posters"));
}
if (
title.backdropPath &&
!isImageCached("backdrops", path.basename(title.backdropPath))
) {
tasks.push(downloadAndCacheImage(title.backdropPath, "backdrops"));
}
// Season posters
if (title.type === "tv") {
const allSeasons = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
for (const s of allSeasons) {
if (
s.posterPath &&
!isImageCached("posters", path.basename(s.posterPath))
) {
tasks.push(downloadAndCacheImage(s.posterPath, "posters"));
}
}
}
await Promise.allSettled(tasks);
}
export async function cacheEpisodeStills(titleId: string) {
const allSeasons = await db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
for (const s of allSeasons) {
const eps = await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
const tasks: Promise<unknown>[] = [];
for (const ep of eps) {
if (
ep.stillPath &&
!isImageCached("stills", path.basename(ep.stillPath))
) {
tasks.push(downloadAndCacheImage(ep.stillPath, "stills"));
}
}
await Promise.allSettled(tasks);
}
}
export async function cacheProviderLogos(titleId: string) {
const offers = await db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
.all();
const tasks: Promise<unknown>[] = [];
const seen = new Set<string>();
for (const offer of offers) {
if (offer.logoPath) {
const basename = path.basename(offer.logoPath);
if (!seen.has(basename) && !isImageCached("logos", basename)) {
seen.add(basename);
tasks.push(downloadAndCacheImage(offer.logoPath, "logos"));
}
}
}
await Promise.allSettled(tasks);
}