feat: Beispielbibliothek mit lokal erzeugten Postern
Flimmer ist eine posterlastige Anwendung — ohne Bilder sieht die Beispielbibliothek aus wie ein Fehler statt wie eine Bibliothek. Echte TMDB-Poster lassen sich nicht mitliefern, also entstehen abstrakte Poster im Flimmer-Look direkt im Bild-Cache. Der Katalog ist bewusst erfunden: das Seed-Skript soll ohne TMDB-Schlüssel laufen und keine fremden Metadaten ins Repository ziehen. Die Sichtungen sind über die letzten Monate verteilt, damit Kalender und Statistik später etwas zu zeigen haben.
This commit is contained in:
+466
-12
@@ -3,13 +3,19 @@
|
||||
*
|
||||
* npm run db:seed
|
||||
*
|
||||
* Das Skript ist idempotent: mehrfaches Ausführen legt nichts doppelt an.
|
||||
* Es fasst ausschließlich die Demo-Konten und deren Daten an — eine echte
|
||||
* Instanz nimmt dadurch keinen Schaden, sollte es versehentlich laufen.
|
||||
* 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");
|
||||
@@ -22,8 +28,440 @@ const prisma = new PrismaClient({
|
||||
});
|
||||
|
||||
const DEMO_PASSWORD = "flimmer-demo-2026";
|
||||
const DATA_DIR = process.env.DATA_DIR || "./data";
|
||||
const REGION = process.env.TMDB_REGION || "DE";
|
||||
|
||||
async function seedUsers() {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("Seed: Demo-Konten ...");
|
||||
const passwordHash = await bcrypt.hash(DEMO_PASSWORD, 12);
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
@@ -34,7 +472,7 @@ async function seedUsers() {
|
||||
name: "Demo Nutzer",
|
||||
passwordHash,
|
||||
role: "ADMIN",
|
||||
region: "DE",
|
||||
region: REGION,
|
||||
publicProfile: true,
|
||||
profileSlug: "demo",
|
||||
},
|
||||
@@ -48,19 +486,35 @@ async function seedUsers() {
|
||||
name: "Gast Nutzerin",
|
||||
passwordHash,
|
||||
role: "USER",
|
||||
region: "DE",
|
||||
region: REGION,
|
||||
},
|
||||
});
|
||||
|
||||
return { admin, friend };
|
||||
}
|
||||
console.log("Seed: Streamingdienste ...");
|
||||
const providerIds = await seedProviders();
|
||||
|
||||
async function main() {
|
||||
console.log("Seed: lege Demo-Konten an ...");
|
||||
const { admin, friend } = await seedUsers();
|
||||
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);
|
||||
|
||||
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. Anmelden mit:");
|
||||
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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user