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,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