/** * 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 = { 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(); 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, ) { 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) { 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) { 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; /** Liegt der Titel in der eigenen Sammlung? */ owned?: boolean; ownedSource?: string; }; 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, owned: true, ownedSource: "Jellyfin" }, { key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320, owned: true, ownedSource: "Jellyfin" }, { 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, owned: true, ownedSource: "Blu-ray" }, { 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) { 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, owned: plan.owned ?? false, ownedSource: plan.ownedSource ?? null, 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, owned: plan.owned ?? false, ownedSource: plan.ownedSource ?? null, 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) { 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(); 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(); 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(); });