Files
flimmer/prisma/seed.ts
T

606 lines
18 KiB
TypeScript

/**
* Beispieldaten für die Entwicklung.
*
* npm run db:seed
*
* Legt zwei Demo-Konten, einen erfundenen Katalog mit lokal erzeugten Postern
* und eine gelebte Bibliothek an: laufende Serien, abgeschlossene, ein
* Rewatch, eine Watchlist und Sichtungen über die letzten Monate verteilt —
* damit Übersicht, Kalender und Statistik etwas zu zeigen haben.
*
* Das Skript ist idempotent und fasst ausschließlich die Demo-Daten an
* (Konten auf @flimmer.local, Titel mit TMDB-IDs ab 9.100.000).
*/
import { PrismaPg } from "@prisma/adapter-pg";
import bcrypt from "bcryptjs";
import { PrismaClient } from "../src/generated/prisma/client";
import { DEMO_MOVIES, DEMO_SERIES, demoTmdbId, type DemoMovie, type DemoSeries } from "./demo/catalog";
import { generateArtwork } from "./demo/posters";
try {
process.loadEnvFile(".env");
} catch {
// Werte kommen aus der Umgebung.
}
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
const DEMO_PASSWORD = "flimmer-demo-2026";
const DATA_DIR = process.env.DATA_DIR || "./data";
const REGION = process.env.TMDB_REGION || "DE";
const PROVIDER_SEED: Record<string, { tmdbId: number; color: string }> = {
Netflix: { tmdbId: 8, color: "#e50914" },
"Amazon Prime Video": { tmdbId: 119, color: "#00a8e1" },
"Disney Plus": { tmdbId: 337, color: "#0e47a1" },
"Apple TV+": { tmdbId: 350, color: "#8f8f8f" },
WOW: { tmdbId: 30, color: "#00a4e4" },
Joyn: { tmdbId: 305, color: "#f5008c" },
"ARD Mediathek": { tmdbId: 216, color: "#0a3b78" },
"ZDF Mediathek": { tmdbId: 217, color: "#fa7d19" },
ARTE: { tmdbId: 234, color: "#ff6600" },
"Google Play Movies": { tmdbId: 3, color: "#34a853" },
};
function date(value: string): Date {
return new Date(`${value}T00:00:00.000Z`);
}
/** Tage vor heute — für Sichtungen, die sich über die Zeit verteilen sollen. */
function daysAgo(days: number, hour = 21): Date {
const result = new Date();
result.setDate(result.getDate() - days);
result.setHours(hour, 15, 0, 0);
return result;
}
// ---------------------------------------------------------------------------
// Katalog
// ---------------------------------------------------------------------------
async function seedProviders() {
const map = new Map<string, string>();
for (const [name, info] of Object.entries(PROVIDER_SEED)) {
const row = await prisma.watchProvider.upsert({
where: { tmdbId: info.tmdbId },
// logoPath bleibt leer: für die erfundenen Demo-Dienste gibt es keine
// echten Logos, und ein toter Pfad löst bei jedem Aufruf eine 404 aus.
create: { tmdbId: info.tmdbId, name, color: info.color, logoPath: null },
update: { name, color: info.color, logoPath: null },
select: { id: true },
});
map.set(name, row.id);
}
return map;
}
async function linkProviders(
titleId: string,
providers: { name: string; kind: string }[],
providerIds: Map<string, string>,
) {
for (const entry of providers) {
const providerId = providerIds.get(entry.name);
if (!providerId) continue;
await prisma.titleProvider.upsert({
where: {
titleId_providerId_region_kind: {
titleId,
providerId,
region: REGION,
kind: entry.kind as "FLATRATE",
},
},
create: { titleId, providerId, region: REGION, kind: entry.kind as "FLATRATE" },
update: {},
});
}
}
async function seedSeries(series: DemoSeries, providerIds: Map<string, string>) {
const art = await generateArtwork(DATA_DIR, {
title: series.title,
subtitle: `${series.firstAirDate.slice(0, 4)} · ${series.genres[0] ?? "Serie"}`,
seed: series.key,
});
const tmdbId = demoTmdbId(series.key);
const common = {
title: series.title,
originalTitle: series.originalTitle ?? null,
overview: series.overview,
tagline: series.tagline ?? null,
posterPath: art.posterPath,
backdropPath: art.backdropPath,
releaseDate: date(series.firstAirDate),
lastAirDate: series.lastAirDate ? date(series.lastAirDate) : null,
runtime: series.runtime,
status: series.status,
inProduction: series.inProduction,
genres: series.genres,
originalLang: "de",
voteAverage: series.voteAverage,
syncedAt: new Date(),
};
const title = await prisma.title.upsert({
where: { type_tmdbId: { type: "SERIES", tmdbId } },
create: { type: "SERIES", tmdbId, ...common },
update: common,
});
let episodeCount = 0;
for (const season of series.seasons) {
const seasonRow = await prisma.season.upsert({
where: { titleId_seasonNumber: { titleId: title.id, seasonNumber: season.seasonNumber } },
create: {
titleId: title.id,
seasonNumber: season.seasonNumber,
name: season.name,
airDate: date(season.airDate),
episodeCount: season.episodes.length,
posterPath: art.posterPath,
},
update: { name: season.name, episodeCount: season.episodes.length },
select: { id: true },
});
for (const [index, name] of season.episodes.entries()) {
// Eine Episode pro Woche ab dem Staffelstart.
const airDate = new Date(date(season.airDate).getTime() + index * 7 * 86_400_000);
const payload = {
seasonId: seasonRow.id,
name,
overview: null,
airDate,
runtime: season.runtime,
};
await prisma.episode.upsert({
where: {
titleId_seasonNumber_episodeNumber: {
titleId: title.id,
seasonNumber: season.seasonNumber,
episodeNumber: index + 1,
},
},
create: {
titleId: title.id,
seasonNumber: season.seasonNumber,
episodeNumber: index + 1,
...payload,
},
update: payload,
});
episodeCount += 1;
}
}
await prisma.title.update({
where: { id: title.id },
data: { seasonCount: series.seasons.length, episodeCount },
});
await linkProviders(title.id, series.providers, providerIds);
return title;
}
async function seedMovie(movie: DemoMovie, providerIds: Map<string, string>) {
const art = await generateArtwork(DATA_DIR, {
title: movie.title,
subtitle: `${movie.releaseDate.slice(0, 4)} · ${movie.genres[0] ?? "Film"}`,
seed: movie.key,
});
const tmdbId = demoTmdbId(movie.key);
const collectionId = movie.collection ? 9_500_000 + (movie.collection.length % 100) : null;
const common = {
title: movie.title,
originalTitle: movie.originalTitle ?? null,
overview: movie.overview,
tagline: movie.tagline ?? null,
posterPath: art.posterPath,
backdropPath: art.backdropPath,
releaseDate: date(movie.releaseDate),
runtime: movie.runtime,
status: "Released",
genres: movie.genres,
originalLang: "de",
voteAverage: movie.voteAverage,
tmdbCollectionId: collectionId,
tmdbCollectionName: movie.collection ?? null,
syncedAt: new Date(),
};
const title = await prisma.title.upsert({
where: { type_tmdbId: { type: "MOVIE", tmdbId } },
create: { type: "MOVIE", tmdbId, ...common },
update: common,
});
await linkProviders(title.id, movie.providers, providerIds);
return title;
}
// ---------------------------------------------------------------------------
// Bibliothek
// ---------------------------------------------------------------------------
type EntryPlan = {
key: string;
status: "WATCHING" | "PAUSED" | "COMPLETED" | "DROPPED" | "WATCHLIST";
rating?: number;
notes?: string;
tags?: string[];
favorite?: boolean;
/** Wie viele reguläre Episoden im aktuellen Durchlauf gesehen sind. */
watchedEpisodes?: number;
/** Vollständig gesehene frühere Durchläufe. */
previousRuns?: number;
/** Film gesehen? */
seen?: boolean;
/** Erste Sichtung vor wie vielen Tagen (die übrigen folgen im Abstand). */
startedDaysAgo?: number;
/** Abstand zwischen zwei Episoden in Tagen. */
spacingDays?: number;
};
const ENTRY_PLANS: EntryPlan[] = [
{
key: "nordlicht",
status: "WATCHING",
rating: 9,
favorite: true,
tags: ["abends", "mit anna"],
notes: "Staffel 2 läuft noch — bloß nichts spoilern lassen.",
watchedEpisodes: 10,
startedDaysAgo: 120,
spacingDays: 9,
},
{
key: "kaffeesatz",
status: "COMPLETED",
rating: 8,
tags: ["nebenbei"],
watchedEpisodes: 28,
startedDaysAgo: 300,
spacingDays: 8,
},
{
key: "sternenpfad-serie",
status: "COMPLETED",
rating: 9,
tags: ["sternenpfad"],
watchedEpisodes: 16,
startedDaysAgo: 210,
spacingDays: 6,
},
{
key: "sternenpfad-akademie",
status: "WATCHING",
tags: ["sternenpfad"],
watchedEpisodes: 4,
startedDaysAgo: 26,
spacingDays: 6,
},
{
key: "aktenlage",
status: "PAUSED",
rating: 7,
notes: "Gut, aber ich brauche dafür Ruhe. Später weiterschauen.",
watchedEpisodes: 4,
startedDaysAgo: 95,
spacingDays: 4,
},
{
key: "tiefenschaerfe",
status: "WATCHING",
rating: 8,
tags: ["mit anna"],
notes: "Zweiter Durchlauf — diesmal auf die Fotos achten.",
previousRuns: 1,
watchedEpisodes: 2,
startedDaysAgo: 14,
spacingDays: 3,
},
{
key: "gartenjahr",
status: "WATCHING",
rating: 7,
tags: ["nebenbei"],
watchedEpisodes: 8,
startedDaysAgo: 70,
spacingDays: 7,
},
{ key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] },
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340 },
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320 },
{ key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 },
{ key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] },
{ key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] },
{
key: "stille-bilder",
status: "COMPLETED",
rating: 8,
seen: true,
previousRuns: 1,
notes: "Beim zweiten Mal deutlich besser.",
startedDaysAgo: 180,
},
{ key: "stille-bilder-2", status: "WATCHLIST" },
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60 },
{ key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 },
{ key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 },
];
async function seedLibrary(userId: string, titleIds: Map<string, string>) {
for (const plan of ENTRY_PLANS) {
const titleId = titleIds.get(plan.key);
if (!titleId) continue;
const title = await prisma.title.findUniqueOrThrow({
where: { id: titleId },
select: { id: true, type: true, runtime: true },
});
const currentRun = plan.previousRuns ?? 0;
const entry = await prisma.libraryEntry.upsert({
where: { userId_titleId: { userId, titleId } },
create: {
userId,
titleId,
status: plan.status,
rating: plan.rating ?? null,
notes: plan.notes ?? null,
favorite: plan.favorite ?? false,
currentRun,
startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null,
finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null,
},
update: {
status: plan.status,
rating: plan.rating ?? null,
notes: plan.notes ?? null,
favorite: plan.favorite ?? false,
currentRun,
},
});
// „Wo schaue ich das" aus der Verfügbarkeit vorbelegen
const provider = await prisma.titleProvider.findFirst({
where: { titleId, region: REGION, kind: { in: ["FLATRATE", "FREE", "ADS"] } },
include: { provider: { select: { name: true } } },
});
if (provider) {
await prisma.libraryEntry.update({
where: { id: entry.id },
data: { whereToWatch: provider.provider.name },
});
}
if (plan.tags?.length) {
for (const name of plan.tags) {
const tag = await prisma.tag.upsert({
where: { userId_name: { userId, name } },
create: { userId, name },
update: {},
select: { id: true },
});
await prisma.tagOnEntry.upsert({
where: { tagId_entryId: { tagId: tag.id, entryId: entry.id } },
create: { tagId: tag.id, entryId: entry.id },
update: {},
});
}
}
await prisma.watch.deleteMany({ where: { userId, entryId: entry.id } });
if (title.type === "MOVIE") {
if (!plan.seen) continue;
for (let run = 0; run <= currentRun; run += 1) {
await prisma.watch.create({
data: {
userId,
entryId: entry.id,
titleId,
run,
watchedAt: daysAgo((plan.startedDaysAgo ?? 30) - run * 25),
minutes: title.runtime ?? 0,
},
});
}
continue;
}
const episodes = await prisma.episode.findMany({
where: { titleId, seasonNumber: { gt: 0 } },
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: { id: true, runtime: true },
});
// Frühere Durchläufe: vollständig, weiter in der Vergangenheit.
for (let run = 0; run < currentRun; run += 1) {
for (const [index, episode] of episodes.entries()) {
await prisma.watch.create({
data: {
userId,
entryId: entry.id,
titleId,
episodeId: episode.id,
run,
watchedAt: daysAgo((plan.startedDaysAgo ?? 60) + 120 - index * 2),
minutes: episode.runtime ?? title.runtime ?? 0,
},
});
}
}
const watched = episodes.slice(0, plan.watchedEpisodes ?? 0);
const start = plan.startedDaysAgo ?? 60;
const spacing = plan.spacingDays ?? 5;
for (const [index, episode] of watched.entries()) {
await prisma.watch.create({
data: {
userId,
entryId: entry.id,
titleId,
episodeId: episode.id,
run: currentRun,
watchedAt: daysAgo(Math.max(0, start - index * spacing)),
minutes: episode.runtime ?? title.runtime ?? 0,
},
});
}
}
}
// ---------------------------------------------------------------------------
// Universum
// ---------------------------------------------------------------------------
/**
* Die Erscheinungsreihenfolge berechnet Flimmer selbst — hier steht nur die
* abweichende Story-Chronologie, damit die Seite zwei echte Reihenfolgen zeigt.
*/
const CHRONOLOGICAL = [
"sternenpfad-null",
"sternenpfad-1",
"sternenpfad-akademie",
"sternenpfad-2",
"sternenpfad-serie",
"sternenpfad-3",
"sternenpfad-4",
];
async function seedUniverse(ownerId: string, titleIds: Map<string, string>) {
const existing = await prisma.universe.findUnique({
where: { ownerId_slug: { ownerId, slug: "sternenpfad" } },
select: { id: true },
});
if (existing) {
await prisma.universe.delete({ where: { id: existing.id } });
}
const universe = await prisma.universe.create({
data: {
ownerId,
name: "Sternenpfad",
slug: "sternenpfad",
description:
"Fünf Filme und zwei Serien. Die Kinoreihenfolge und die Story-Chronologie gehen auseinander — deshalb zwei Reihenfolgen.",
orders: {
create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }],
},
},
select: { id: true },
});
const itemIdByKey = new Map<string, string>();
for (const key of CHRONOLOGICAL) {
const titleId = titleIds.get(key);
if (!titleId) continue;
const item = await prisma.universeItem.create({
data: { universeId: universe.id, titleId },
select: { id: true },
});
itemIdByKey.set(key, item.id);
}
const order = await prisma.universeOrder.create({
data: { universeId: universe.id, name: "Chronologisch", kind: "CUSTOM" },
select: { id: true },
});
await prisma.universeOrderItem.createMany({
data: CHRONOLOGICAL.flatMap((key, index) => {
const itemId = itemIdByKey.get(key);
return itemId ? [{ orderId: order.id, itemId, position: index }] : [];
}),
});
return universe;
}
// ---------------------------------------------------------------------------
async function main() {
console.log("Seed: Demo-Konten ...");
const passwordHash = await bcrypt.hash(DEMO_PASSWORD, 12);
const admin = await prisma.user.upsert({
where: { email: "demo@flimmer.local" },
update: {},
create: {
email: "demo@flimmer.local",
name: "Demo Nutzer",
passwordHash,
role: "ADMIN",
region: REGION,
publicProfile: true,
profileSlug: "demo",
},
});
const friend = await prisma.user.upsert({
where: { email: "gast@flimmer.local" },
update: {},
create: {
email: "gast@flimmer.local",
name: "Gast Nutzerin",
passwordHash,
role: "USER",
region: REGION,
},
});
console.log("Seed: Streamingdienste ...");
const providerIds = await seedProviders();
console.log("Seed: Katalog mit Postern ...");
const titleIds = new Map<string, string>();
for (const series of DEMO_SERIES) {
const row = await seedSeries(series, providerIds);
titleIds.set(series.key, row.id);
console.log(` Serie ${series.title}`);
}
for (const movie of DEMO_MOVIES) {
const row = await seedMovie(movie, providerIds);
titleIds.set(movie.key, row.id);
console.log(` Film ${movie.title}`);
}
console.log("Seed: Bibliothek und Sichtungen ...");
await seedLibrary(admin.id, titleIds);
console.log("Seed: Universum ...");
const universe = await seedUniverse(admin.id, titleIds);
// Mit dem zweiten Konto teilen, damit die Freigabe sichtbar ist.
await prisma.universeMember.upsert({
where: { universeId_userId: { universeId: universe.id, userId: friend.id } },
create: { universeId: universe.id, userId: friend.id, permission: "READ" },
update: {},
});
const watches = await prisma.watch.count({ where: { userId: admin.id } });
const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } });
console.log("");
console.log(`Fertig: ${entries} Einträge, ${watches} Sichtungen.`);
console.log("Anmelden mit:");
console.log(` ${admin.email} / ${DEMO_PASSWORD} (Administrator)`);
console.log(` ${friend.email} / ${DEMO_PASSWORD}`);
}
main()
.catch((error) => {
console.error(error);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});