Files
flimmer/src/lib/catalog.ts
T
Florian Wilde cc0e498fe6 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.
2026-09-01 19:00:12 +02:00

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