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.
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
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 };
|
|
}
|