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 }; }