feat: Katalogschicht und lokaler Bild-Cache
Der Katalog ist bewusst gemeinsam für alle Nutzer und über (Typ, TMDB-ID) dedupliziert: zehn Leute mit derselben Serie brauchen die Episodenliste nur einmal. Persönliches steckt ausschließlich in LibraryEntry und Watch. Poster laufen über /api/image statt direkt zu TMDB — einmal holen, danach vom eigenen Server ausliefern. Größe und Pfad werden streng geprüft, damit daraus kein offener Proxy wird. Staffel 0 (Specials) wird importiert, zählt aber nicht zum Fortschritt; Episoden ohne eigene Laufzeit fallen auf die durchschnittliche Episodenlänge der Serie zurück.
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getCachedImage, isValidImagePath, isValidSize } from "@/lib/images";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausliefern zwischengespeicherter TMDB-Bilder.
|
||||||
|
*
|
||||||
|
* /api/image?size=w342&path=/abc123.jpg
|
||||||
|
*
|
||||||
|
* Größe und Pfad werden streng geprüft — der Endpunkt soll kein offener Proxy
|
||||||
|
* für beliebige Adressen werden.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const size = url.searchParams.get("size") ?? "w342";
|
||||||
|
const path = url.searchParams.get("path") ?? "";
|
||||||
|
|
||||||
|
if (!isValidSize(size) || !isValidImagePath(path)) {
|
||||||
|
return NextResponse.json({ error: "Ungültige Bildanfrage." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = await getCachedImage(size, path);
|
||||||
|
return new NextResponse(new Uint8Array(image.data), {
|
||||||
|
headers: {
|
||||||
|
"content-type": image.contentType,
|
||||||
|
// Bilder unter einem TMDB-Pfad ändern sich nicht mehr.
|
||||||
|
"cache-control": "public, max-age=31536000, immutable",
|
||||||
|
"x-flimmer-cache": image.fromCache ? "hit" : "miss",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Bild nicht verfügbar." }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import {
|
||||||
|
clearTmdbCache,
|
||||||
|
getCollection,
|
||||||
|
getMovie,
|
||||||
|
getSeason,
|
||||||
|
getSeries,
|
||||||
|
parseDate,
|
||||||
|
type TmdbProvider,
|
||||||
|
type TmdbWatchProviders,
|
||||||
|
} from "./tmdb";
|
||||||
|
import type { ProviderKind, Title, TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernahme von TMDB-Daten in den lokalen Katalog.
|
||||||
|
*
|
||||||
|
* Der Katalog ist bewusst **gemeinsam** für alle Nutzer und über
|
||||||
|
* (Typ, TMDB-ID) dedupliziert: zehn Leute mit derselben Serie brauchen die
|
||||||
|
* Episodenliste nur einmal. Persönliches steckt ausschließlich in
|
||||||
|
* LibraryEntry und Watch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Staffel 0 sind Specials — sie zählen nicht zum regulären Fortschritt. */
|
||||||
|
export const SPECIALS_SEASON = 0;
|
||||||
|
|
||||||
|
/** Verwirft den TMDB-Antwortspeicher (Tests, Verwaltung „jetzt neu laden"). */
|
||||||
|
export function clearCatalogCaches() {
|
||||||
|
clearTmdbCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Streamingdienste -------------------------------------------------------
|
||||||
|
|
||||||
|
const PROVIDER_KIND_MAP = {
|
||||||
|
flatrate: "FLATRATE",
|
||||||
|
rent: "RENT",
|
||||||
|
buy: "BUY",
|
||||||
|
ads: "ADS",
|
||||||
|
free: "FREE",
|
||||||
|
} as const satisfies Record<string, ProviderKind>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiedererkennbare Akzentfarben der großen Dienste. Alles Unbekannte bekommt
|
||||||
|
* im UI einen neutralen Ton — deshalb ist die Liste nur eine Abkürzung, keine
|
||||||
|
* Voraussetzung.
|
||||||
|
*/
|
||||||
|
const PROVIDER_COLORS: Record<string, string> = {
|
||||||
|
netflix: "#e50914",
|
||||||
|
"amazon prime video": "#00a8e1",
|
||||||
|
"prime video": "#00a8e1",
|
||||||
|
"disney plus": "#0e47a1",
|
||||||
|
"disney+": "#0e47a1",
|
||||||
|
"apple tv+": "#8f8f8f",
|
||||||
|
"apple tv plus": "#8f8f8f",
|
||||||
|
"apple tv": "#8f8f8f",
|
||||||
|
wow: "#00a4e4",
|
||||||
|
"sky go": "#00a4e4",
|
||||||
|
paramount: "#0064ff",
|
||||||
|
"paramount plus": "#0064ff",
|
||||||
|
"rtl+": "#e30613",
|
||||||
|
joyn: "#f5008c",
|
||||||
|
"joyn plus": "#f5008c",
|
||||||
|
crunchyroll: "#f47521",
|
||||||
|
mubi: "#000000",
|
||||||
|
ard: "#0a3b78",
|
||||||
|
"ard mediathek": "#0a3b78",
|
||||||
|
zdf: "#fa7d19",
|
||||||
|
"zdf mediathek": "#fa7d19",
|
||||||
|
arte: "#ff6600",
|
||||||
|
magentatv: "#e20074",
|
||||||
|
"wow presents plus": "#00a4e4",
|
||||||
|
"google play movies": "#34a853",
|
||||||
|
"youtube premium": "#ff0000",
|
||||||
|
youtube: "#ff0000",
|
||||||
|
"maxdome store": "#ff6d00",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function upsertProvider(provider: TmdbProvider) {
|
||||||
|
const color = PROVIDER_COLORS[provider.provider_name.trim().toLowerCase()] ?? null;
|
||||||
|
return prisma.watchProvider.upsert({
|
||||||
|
where: { tmdbId: provider.provider_id },
|
||||||
|
create: {
|
||||||
|
tmdbId: provider.provider_id,
|
||||||
|
name: provider.provider_name,
|
||||||
|
logoPath: provider.logo_path ?? null,
|
||||||
|
color,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: provider.provider_name,
|
||||||
|
logoPath: provider.logo_path ?? null,
|
||||||
|
...(color ? { color } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt die Verfügbarkeit eines Titels für eine Region fort.
|
||||||
|
*
|
||||||
|
* Einträge, die es nicht mehr gibt, werden gelöscht; vorhandene bleiben
|
||||||
|
* unangetastet, damit `firstSeenAt` erhalten bleibt — daran hängt die
|
||||||
|
* Benachrichtigung „ist jetzt verfügbar".
|
||||||
|
*/
|
||||||
|
async function syncProviders(titleId: string, data: TmdbWatchProviders | undefined, region: string) {
|
||||||
|
const regional = data?.results?.[region];
|
||||||
|
const seen: { providerId: string; kind: ProviderKind }[] = [];
|
||||||
|
|
||||||
|
if (regional) {
|
||||||
|
for (const [key, kind] of Object.entries(PROVIDER_KIND_MAP)) {
|
||||||
|
const list = regional[key as keyof typeof regional];
|
||||||
|
if (!Array.isArray(list)) continue;
|
||||||
|
for (const provider of list as TmdbProvider[]) {
|
||||||
|
const row = await upsertProvider(provider);
|
||||||
|
seen.push({ providerId: row.id, kind });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.titleProvider.deleteMany({
|
||||||
|
where: {
|
||||||
|
titleId,
|
||||||
|
region,
|
||||||
|
NOT: seen.map(({ providerId, kind }) => ({ providerId, kind })),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...seen.map(({ providerId, kind }) =>
|
||||||
|
prisma.titleProvider.upsert({
|
||||||
|
where: { titleId_providerId_region_kind: { titleId, providerId, region, kind } },
|
||||||
|
create: { titleId, providerId, region, kind },
|
||||||
|
update: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filme ------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function importMovie(tmdbId: number, region = env.tmdb.region): Promise<Title> {
|
||||||
|
const data = await getMovie(tmdbId);
|
||||||
|
|
||||||
|
const title = await prisma.title.upsert({
|
||||||
|
where: { type_tmdbId: { type: "MOVIE", tmdbId } },
|
||||||
|
create: {
|
||||||
|
type: "MOVIE",
|
||||||
|
tmdbId,
|
||||||
|
imdbId: data.imdb_id ?? null,
|
||||||
|
title: data.title,
|
||||||
|
originalTitle: data.original_title ?? null,
|
||||||
|
overview: data.overview ?? null,
|
||||||
|
tagline: data.tagline ?? null,
|
||||||
|
posterPath: data.poster_path ?? null,
|
||||||
|
backdropPath: data.backdrop_path ?? null,
|
||||||
|
releaseDate: parseDate(data.release_date),
|
||||||
|
runtime: data.runtime ?? null,
|
||||||
|
status: data.status ?? null,
|
||||||
|
genres: data.genres?.map((g) => g.name) ?? [],
|
||||||
|
originalLang: data.original_language ?? null,
|
||||||
|
homepage: data.homepage ?? null,
|
||||||
|
voteAverage: data.vote_average ?? null,
|
||||||
|
tmdbCollectionId: data.belongs_to_collection?.id ?? null,
|
||||||
|
tmdbCollectionName: data.belongs_to_collection?.name ?? null,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
imdbId: data.imdb_id ?? null,
|
||||||
|
title: data.title,
|
||||||
|
originalTitle: data.original_title ?? null,
|
||||||
|
overview: data.overview ?? null,
|
||||||
|
tagline: data.tagline ?? null,
|
||||||
|
posterPath: data.poster_path ?? null,
|
||||||
|
backdropPath: data.backdrop_path ?? null,
|
||||||
|
releaseDate: parseDate(data.release_date),
|
||||||
|
runtime: data.runtime ?? null,
|
||||||
|
status: data.status ?? null,
|
||||||
|
genres: data.genres?.map((g) => g.name) ?? [],
|
||||||
|
originalLang: data.original_language ?? null,
|
||||||
|
homepage: data.homepage ?? null,
|
||||||
|
voteAverage: data.vote_average ?? null,
|
||||||
|
tmdbCollectionId: data.belongs_to_collection?.id ?? null,
|
||||||
|
tmdbCollectionName: data.belongs_to_collection?.name ?? null,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncProviders(title.id, data["watch/providers"], region);
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Serien -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ImportSeriesOptions = {
|
||||||
|
region?: string;
|
||||||
|
/** Nur Kopfdaten holen — spart bei der Suche die Episodenabrufe. */
|
||||||
|
skipEpisodes?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function importSeries(
|
||||||
|
tmdbId: number,
|
||||||
|
{ region = env.tmdb.region, skipEpisodes = false }: ImportSeriesOptions = {},
|
||||||
|
): Promise<Title> {
|
||||||
|
const data = await getSeries(tmdbId);
|
||||||
|
const averageRuntime = data.episode_run_time?.[0] ?? null;
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
imdbId: data.external_ids?.imdb_id ?? null,
|
||||||
|
title: data.name,
|
||||||
|
originalTitle: data.original_name ?? null,
|
||||||
|
overview: data.overview ?? null,
|
||||||
|
tagline: data.tagline ?? null,
|
||||||
|
posterPath: data.poster_path ?? null,
|
||||||
|
backdropPath: data.backdrop_path ?? null,
|
||||||
|
releaseDate: parseDate(data.first_air_date),
|
||||||
|
lastAirDate: parseDate(data.last_air_date),
|
||||||
|
runtime: averageRuntime,
|
||||||
|
status: data.status ?? null,
|
||||||
|
inProduction: data.in_production ?? false,
|
||||||
|
genres: data.genres?.map((g) => g.name) ?? [],
|
||||||
|
originalLang: data.original_language ?? null,
|
||||||
|
homepage: data.homepage ?? null,
|
||||||
|
voteAverage: data.vote_average ?? null,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = await prisma.title.upsert({
|
||||||
|
where: { type_tmdbId: { type: "SERIES", tmdbId } },
|
||||||
|
create: { type: "SERIES", tmdbId, ...common },
|
||||||
|
update: common,
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncProviders(title.id, data["watch/providers"], region);
|
||||||
|
|
||||||
|
if (skipEpisodes) return title;
|
||||||
|
|
||||||
|
await importSeasons(title.id, tmdbId, data.seasons ?? [], averageRuntime);
|
||||||
|
|
||||||
|
// Erneut lesen: importSeasons schreibt Staffel- und Episodenzähler fort,
|
||||||
|
// das oben zurückgegebene Objekt wäre an dieser Stelle veraltet.
|
||||||
|
return prisma.title.findUniqueOrThrow({ where: { id: title.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSeasons(
|
||||||
|
titleId: string,
|
||||||
|
tmdbId: number,
|
||||||
|
seasons: NonNullable<Awaited<ReturnType<typeof getSeries>>["seasons"]>,
|
||||||
|
fallbackRuntime: number | null,
|
||||||
|
) {
|
||||||
|
for (const summary of seasons) {
|
||||||
|
const season = await prisma.season.upsert({
|
||||||
|
where: { titleId_seasonNumber: { titleId, seasonNumber: summary.season_number } },
|
||||||
|
create: {
|
||||||
|
titleId,
|
||||||
|
tmdbId: summary.id,
|
||||||
|
seasonNumber: summary.season_number,
|
||||||
|
name: summary.name,
|
||||||
|
overview: summary.overview ?? null,
|
||||||
|
posterPath: summary.poster_path ?? null,
|
||||||
|
airDate: parseDate(summary.air_date),
|
||||||
|
episodeCount: summary.episode_count ?? 0,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
tmdbId: summary.id,
|
||||||
|
name: summary.name,
|
||||||
|
overview: summary.overview ?? null,
|
||||||
|
posterPath: summary.poster_path ?? null,
|
||||||
|
airDate: parseDate(summary.air_date),
|
||||||
|
episodeCount: summary.episode_count ?? 0,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const details = await getSeason(tmdbId, summary.season_number);
|
||||||
|
for (const episode of details.episodes ?? []) {
|
||||||
|
const payload = {
|
||||||
|
tmdbId: episode.id,
|
||||||
|
seasonId: season.id,
|
||||||
|
name: episode.name,
|
||||||
|
overview: episode.overview ?? null,
|
||||||
|
stillPath: episode.still_path ?? null,
|
||||||
|
airDate: parseDate(episode.air_date),
|
||||||
|
runtime: episode.runtime ?? fallbackRuntime,
|
||||||
|
};
|
||||||
|
await prisma.episode.upsert({
|
||||||
|
where: {
|
||||||
|
titleId_seasonNumber_episodeNumber: {
|
||||||
|
titleId,
|
||||||
|
seasonNumber: episode.season_number,
|
||||||
|
episodeNumber: episode.episode_number,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
titleId,
|
||||||
|
seasonNumber: episode.season_number,
|
||||||
|
episodeNumber: episode.episode_number,
|
||||||
|
...payload,
|
||||||
|
},
|
||||||
|
update: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSeriesCounts(titleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt Staffeln und Episoden neu — Specials (Staffel 0) bleiben außen vor,
|
||||||
|
* damit der Fortschritt nicht an Weihnachtsfolgen hängen bleibt.
|
||||||
|
*/
|
||||||
|
export async function refreshSeriesCounts(titleId: string) {
|
||||||
|
const [seasonCount, episodeCount] = await Promise.all([
|
||||||
|
prisma.season.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
|
||||||
|
prisma.episode.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
|
||||||
|
]);
|
||||||
|
await prisma.title.update({
|
||||||
|
where: { id: titleId },
|
||||||
|
data: { seasonCount, episodeCount },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- gemeinsame Einstiege ---------------------------------------------------
|
||||||
|
|
||||||
|
export function importTitle(type: TitleType, tmdbId: number, region = env.tmdb.region) {
|
||||||
|
return type === "SERIES" ? importSeries(tmdbId, { region }) : importMovie(tmdbId, region);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Titel aus dem lokalen Katalog und holt ihn bei Bedarf von TMDB.
|
||||||
|
* `maxAgeHours` bestimmt, ab wann ein vorhandener Eintrag aufgefrischt wird.
|
||||||
|
*/
|
||||||
|
export async function ensureTitle(
|
||||||
|
type: TitleType,
|
||||||
|
tmdbId: number,
|
||||||
|
{ maxAgeHours = 24 * 7, region = env.tmdb.region } = {},
|
||||||
|
): Promise<Title> {
|
||||||
|
const existing = await prisma.title.findUnique({ where: { type_tmdbId: { type, tmdbId } } });
|
||||||
|
|
||||||
|
if (existing?.syncedAt) {
|
||||||
|
const ageHours = (Date.now() - existing.syncedAt.getTime()) / 3_600_000;
|
||||||
|
if (ageHours < maxAgeHours) return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await importTitle(type, tmdbId, region);
|
||||||
|
} catch (error) {
|
||||||
|
// Steht der Titel bereits im Katalog, ist eine gescheiterte Auffrischung
|
||||||
|
// kein Grund, die Seite platzen zu lassen.
|
||||||
|
if (existing) return existing;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übernimmt eine TMDB-Collection als Liste von Filmen (Grundlage für Universen). */
|
||||||
|
export async function importCollection(collectionTmdbId: number, region = env.tmdb.region) {
|
||||||
|
const collection = await getCollection(collectionTmdbId);
|
||||||
|
const titles: Title[] = [];
|
||||||
|
|
||||||
|
for (const part of collection.parts ?? []) {
|
||||||
|
titles.push(await importMovie(part.id, region));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Erscheinungsdatum, damit die Release-Reihenfolge direkt stimmt.
|
||||||
|
titles.sort((a, b) => (a.releaseDate?.getTime() ?? 0) - (b.releaseDate?.getTime() ?? 0));
|
||||||
|
|
||||||
|
return { collection, titles };
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { IMAGE_BASE } from "./tmdb";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lokaler Bild-Zwischenspeicher.
|
||||||
|
*
|
||||||
|
* Poster und Hintergrundbilder werden einmal von TMDB geholt und danach vom
|
||||||
|
* eigenen Server ausgeliefert. Das spart Anfragen, macht die Oberfläche auch
|
||||||
|
* ohne Internetverbindung nutzbar und verhindert, dass jeder Seitenaufruf
|
||||||
|
* einen Abruf bei einem Dritten auslöst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Zugelassene Größen — verhindert, dass jemand über den Parameter beliebige URLs baut. */
|
||||||
|
export const IMAGE_SIZES = [
|
||||||
|
"w92",
|
||||||
|
"w154",
|
||||||
|
"w185",
|
||||||
|
"w300",
|
||||||
|
"w342",
|
||||||
|
"w500",
|
||||||
|
"w780",
|
||||||
|
"w1280",
|
||||||
|
"original",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ImageSize = (typeof IMAGE_SIZES)[number];
|
||||||
|
|
||||||
|
const PATH_PATTERN = /^\/[A-Za-z0-9]{8,64}\.(jpg|jpeg|png|svg|webp)$/;
|
||||||
|
|
||||||
|
export function isValidImagePath(path: string): boolean {
|
||||||
|
return PATH_PATTERN.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidSize(size: string): size is ImageSize {
|
||||||
|
return (IMAGE_SIZES as readonly string[]).includes(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheDir() {
|
||||||
|
return join(env.dataDir, "images");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheFile(size: string, path: string) {
|
||||||
|
// Der Dateiname wird gehasht, damit keine Pfadanteile aus der Anfrage in
|
||||||
|
// den Dateinamen wandern können.
|
||||||
|
const key = createHash("sha256").update(`${size}${path}`).digest("hex");
|
||||||
|
const extension = path.slice(path.lastIndexOf("."));
|
||||||
|
return join(cacheDir(), `${key}${extension}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CachedImage = {
|
||||||
|
data: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
fromCache: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCachedImage(size: ImageSize, path: string): Promise<CachedImage> {
|
||||||
|
const file = cacheFile(size, path);
|
||||||
|
const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
|
||||||
|
const contentType = CONTENT_TYPES[extension] ?? "application/octet-stream";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await stat(file);
|
||||||
|
if (info.size > 0) {
|
||||||
|
return { data: await readFile(file), contentType, fromCache: true };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Noch nicht im Zwischenspeicher — dann eben holen.
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${IMAGE_BASE}/${size}${path}`, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Bild konnte nicht geladen werden (${response.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
|
// Erst in eine temporäre Datei schreiben und dann umbenennen: so sieht ein
|
||||||
|
// paralleler Abruf nie eine halb geschriebene Datei.
|
||||||
|
await mkdir(cacheDir(), { recursive: true });
|
||||||
|
const temporary = `${file}.${process.pid}.tmp`;
|
||||||
|
await writeFile(temporary, data);
|
||||||
|
await rename(temporary, file);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
contentType: response.headers.get("content-type") ?? contentType,
|
||||||
|
fromCache: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL des eigenen Bild-Endpunkts — überall im UI statt der TMDB-Adresse benutzen. */
|
||||||
|
export function posterUrl(path: string | null | undefined, size: ImageSize = "w342"): string | null {
|
||||||
|
if (!path || !isValidImagePath(path)) return null;
|
||||||
|
return `/api/image?size=${size}&path=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user