606 lines
18 KiB
TypeScript
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();
|
|
});
|