From cc0e498fe67b9192b39c56cf7d1409247da68d0d Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 19:00:12 +0200 Subject: [PATCH] feat: Katalogschicht und lokaler Bild-Cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/image/route.ts | 34 ++++ src/lib/catalog.ts | 366 +++++++++++++++++++++++++++++++++++++ src/lib/images.ts | 107 +++++++++++ 3 files changed, 507 insertions(+) create mode 100644 src/app/api/image/route.ts create mode 100644 src/lib/catalog.ts create mode 100644 src/lib/images.ts diff --git a/src/app/api/image/route.ts b/src/app/api/image/route.ts new file mode 100644 index 0000000..01dec4b --- /dev/null +++ b/src/app/api/image/route.ts @@ -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 }); + } +} diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts new file mode 100644 index 0000000..05a08b0 --- /dev/null +++ b/src/lib/catalog.ts @@ -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; + +/** + * 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 = { + 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 { + 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 }; +} diff --git a/src/lib/images.ts b/src/lib/images.ts new file mode 100644 index 0000000..d6561ed --- /dev/null +++ b/src/lib/images.ts @@ -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)}`; +}