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:
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Beispielkatalog für die Entwicklung.
|
||||
*
|
||||
* Bewusst **erfundene** Titel statt echter TMDB-Daten: das Seed-Skript soll
|
||||
* ohne TMDB-Schlüssel laufen, keine fremden Metadaten ins Repository ziehen
|
||||
* und trotzdem eine Bibliothek zeigen, die sich wie eine echte anfühlt —
|
||||
* laufende Serien, abgeschlossene, ein Universum, unterschiedliche Dienste.
|
||||
*
|
||||
* Die Poster erzeugt `prisma/demo/posters.ts` lokal aus diesen Angaben.
|
||||
*/
|
||||
|
||||
export type DemoSeason = {
|
||||
seasonNumber: number;
|
||||
name: string;
|
||||
airDate: string;
|
||||
/** Episodentitel; die Nummern ergeben sich aus der Reihenfolge. */
|
||||
episodes: string[];
|
||||
runtime: number;
|
||||
};
|
||||
|
||||
export type DemoSeries = {
|
||||
key: string;
|
||||
type: "SERIES";
|
||||
title: string;
|
||||
originalTitle?: string;
|
||||
tagline?: string;
|
||||
overview: string;
|
||||
firstAirDate: string;
|
||||
lastAirDate?: string;
|
||||
status: string;
|
||||
inProduction: boolean;
|
||||
genres: string[];
|
||||
runtime: number;
|
||||
voteAverage: number;
|
||||
providers: { name: string; kind: "FLATRATE" | "FREE" | "RENT" | "BUY" | "ADS" }[];
|
||||
seasons: DemoSeason[];
|
||||
};
|
||||
|
||||
export type DemoMovie = {
|
||||
key: string;
|
||||
type: "MOVIE";
|
||||
title: string;
|
||||
originalTitle?: string;
|
||||
tagline?: string;
|
||||
overview: string;
|
||||
releaseDate: string;
|
||||
runtime: number;
|
||||
genres: string[];
|
||||
voteAverage: number;
|
||||
collection?: string;
|
||||
providers: { name: string; kind: "FLATRATE" | "FREE" | "RENT" | "BUY" | "ADS" }[];
|
||||
};
|
||||
|
||||
export type DemoTitle = DemoSeries | DemoMovie;
|
||||
|
||||
/** Erzeugt Episodentitel im Muster „Kapitel 1 … n" für längere Staffeln. */
|
||||
function chapters(prefix: string, count: number): string[] {
|
||||
return Array.from({ length: count }, (_, index) => `${prefix} ${index + 1}`);
|
||||
}
|
||||
|
||||
export const DEMO_SERIES: DemoSeries[] = [
|
||||
{
|
||||
key: "nordlicht",
|
||||
type: "SERIES",
|
||||
title: "Nordlicht",
|
||||
originalTitle: "Nordlys",
|
||||
tagline: "Manche Lichter wollen nicht gesehen werden.",
|
||||
overview:
|
||||
"Eine Meteorologin kehrt in ihr Heimatdorf am Polarkreis zurück und stößt auf ein Wetterphänomen, das es nicht geben dürfte. Je näher sie der Erklärung kommt, desto weniger will das Dorf sie hören.",
|
||||
firstAirDate: "2023-03-14",
|
||||
lastAirDate: "2025-05-02",
|
||||
status: "Returning Series",
|
||||
inProduction: true,
|
||||
genres: ["Drama", "Mystery"],
|
||||
runtime: 48,
|
||||
voteAverage: 8.1,
|
||||
providers: [
|
||||
{ name: "Netflix", kind: "FLATRATE" },
|
||||
{ name: "WOW", kind: "FLATRATE" },
|
||||
],
|
||||
seasons: [
|
||||
{
|
||||
seasonNumber: 1,
|
||||
name: "Staffel 1",
|
||||
airDate: "2023-03-14",
|
||||
runtime: 48,
|
||||
episodes: [
|
||||
"Ankunft",
|
||||
"Der Messwert",
|
||||
"Polarnacht",
|
||||
"Was der Schnee behält",
|
||||
"Drei Grad zu warm",
|
||||
"Das Gerät",
|
||||
"Nordlys",
|
||||
"Rückkehr des Lichts",
|
||||
],
|
||||
},
|
||||
{
|
||||
seasonNumber: 2,
|
||||
name: "Staffel 2",
|
||||
airDate: "2025-04-11",
|
||||
runtime: 50,
|
||||
episodes: [
|
||||
"Der lange Winter",
|
||||
"Zweiter Frühling",
|
||||
"Messreihe",
|
||||
"Der Bruder",
|
||||
"Vermisst",
|
||||
"Weißes Rauschen",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "kaffeesatz",
|
||||
type: "SERIES",
|
||||
title: "Kaffeesatz",
|
||||
tagline: "Jeder Tisch hat eine Geschichte. Manche stimmen sogar.",
|
||||
overview:
|
||||
"In einem Kölner Café laufen die Leben von sechs Stammgästen zusammen. Halbstünder über Nachbarschaft, Missverständnisse und den Versuch, erwachsen zu wirken.",
|
||||
firstAirDate: "2021-09-02",
|
||||
lastAirDate: "2024-11-21",
|
||||
status: "Ended",
|
||||
inProduction: false,
|
||||
genres: ["Comedy", "Drama"],
|
||||
runtime: 26,
|
||||
voteAverage: 7.6,
|
||||
providers: [
|
||||
{ name: "ARD Mediathek", kind: "FREE" },
|
||||
{ name: "Joyn", kind: "ADS" },
|
||||
],
|
||||
seasons: [
|
||||
{ seasonNumber: 1, name: "Staffel 1", airDate: "2021-09-02", runtime: 26, episodes: chapters("Runde", 10) },
|
||||
{ seasonNumber: 2, name: "Staffel 2", airDate: "2022-10-06", runtime: 26, episodes: chapters("Nachschlag", 10) },
|
||||
{ seasonNumber: 3, name: "Staffel 3", airDate: "2024-09-19", runtime: 27, episodes: chapters("Letzte Bestellung", 8) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-serie",
|
||||
type: "SERIES",
|
||||
title: "Sternenpfad: Die Grenzwacht",
|
||||
tagline: "Am Rand der Karte beginnt die Verantwortung.",
|
||||
overview:
|
||||
"Zwischen dem dritten und dem vierten Film der Sternenpfad-Reihe: eine Grenzstation, zwei verfeindete Flotten und eine Kommandantin, die beide Seiten kennt.",
|
||||
firstAirDate: "2022-11-16",
|
||||
lastAirDate: "2024-01-24",
|
||||
status: "Ended",
|
||||
inProduction: false,
|
||||
genres: ["Science Fiction", "Abenteuer", "Drama"],
|
||||
runtime: 42,
|
||||
voteAverage: 8.4,
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
seasons: [
|
||||
{ seasonNumber: 1, name: "Staffel 1", airDate: "2022-11-16", runtime: 42, episodes: chapters("Wache", 8) },
|
||||
{ seasonNumber: 2, name: "Staffel 2", airDate: "2024-01-24", runtime: 44, episodes: chapters("Ablösung", 8) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-akademie",
|
||||
type: "SERIES",
|
||||
title: "Sternenpfad: Akademie",
|
||||
tagline: "Vor der Flotte kommt die Prüfung.",
|
||||
overview:
|
||||
"Jugendserie im Sternenpfad-Universum: der erste Jahrgang einer neu gegründeten Flottenakademie, kurz nach den Ereignissen des zweiten Films.",
|
||||
firstAirDate: "2026-02-06",
|
||||
status: "Returning Series",
|
||||
inProduction: true,
|
||||
genres: ["Science Fiction", "Abenteuer"],
|
||||
runtime: 38,
|
||||
voteAverage: 7.2,
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
seasons: [
|
||||
{ seasonNumber: 1, name: "Staffel 1", airDate: "2026-02-06", runtime: 38, episodes: chapters("Semester", 10) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "aktenlage",
|
||||
type: "SERIES",
|
||||
title: "Aktenlage",
|
||||
tagline: "Die Wahrheit liegt im Anhang.",
|
||||
overview:
|
||||
"Eine Verwaltungsjuristin deckt in einem Landesministerium einen Vorgang auf, den es offiziell nie gab. Bürokratie-Thriller ohne einen einzigen Schusswechsel.",
|
||||
firstAirDate: "2024-10-03",
|
||||
status: "Returning Series",
|
||||
inProduction: true,
|
||||
genres: ["Thriller", "Drama"],
|
||||
runtime: 52,
|
||||
voteAverage: 8.0,
|
||||
providers: [
|
||||
{ name: "ZDF Mediathek", kind: "FREE" },
|
||||
{ name: "Amazon Prime Video", kind: "FLATRATE" },
|
||||
],
|
||||
seasons: [
|
||||
{ seasonNumber: 1, name: "Staffel 1", airDate: "2024-10-03", runtime: 52, episodes: chapters("Vorgang", 6) },
|
||||
{ seasonNumber: 2, name: "Staffel 2", airDate: "2026-09-24", runtime: 52, episodes: chapters("Widerspruch", 6) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "tiefenschaerfe",
|
||||
type: "SERIES",
|
||||
title: "Tiefenschärfe",
|
||||
tagline: "Sehen ist nicht dasselbe wie erkennen.",
|
||||
overview:
|
||||
"Eine Tatortfotografin merkt, dass auf ihren eigenen Aufnahmen jemand steht, der dort nicht war. Sechs Teile, ein einziger Fall.",
|
||||
firstAirDate: "2025-01-09",
|
||||
lastAirDate: "2025-02-13",
|
||||
status: "Ended",
|
||||
inProduction: false,
|
||||
genres: ["Krimi", "Mystery"],
|
||||
runtime: 45,
|
||||
voteAverage: 7.9,
|
||||
providers: [{ name: "Netflix", kind: "FLATRATE" }],
|
||||
seasons: [
|
||||
{ seasonNumber: 1, name: "Miniserie", airDate: "2025-01-09", runtime: 45, episodes: chapters("Blende", 6) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "gartenjahr",
|
||||
type: "SERIES",
|
||||
title: "Ein Gartenjahr",
|
||||
tagline: "Zwölf Monate, ein Beet, kein Drehbuch.",
|
||||
overview:
|
||||
"Dokureihe: eine Familie legt auf einer Brache in Leipzig einen Gemeinschaftsgarten an. Jede Folge ein Monat.",
|
||||
firstAirDate: "2025-03-01",
|
||||
lastAirDate: "2026-02-01",
|
||||
status: "Ended",
|
||||
inProduction: false,
|
||||
genres: ["Dokumentation"],
|
||||
runtime: 30,
|
||||
voteAverage: 7.4,
|
||||
providers: [{ name: "ARTE", kind: "FREE" }],
|
||||
seasons: [
|
||||
{
|
||||
seasonNumber: 1,
|
||||
name: "Staffel 1",
|
||||
airDate: "2025-03-01",
|
||||
runtime: 30,
|
||||
episodes: [
|
||||
"März: Umgraben",
|
||||
"April: Aussaat",
|
||||
"Mai: Frost",
|
||||
"Juni: Schnecken",
|
||||
"Juli: Zu viel Zucchini",
|
||||
"August: Dürre",
|
||||
"September: Ernte",
|
||||
"Oktober: Einkochen",
|
||||
"November: Laub",
|
||||
"Dezember: Planung",
|
||||
"Januar: Katalog",
|
||||
"Februar: Vorziehen",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "zwischenstopp",
|
||||
type: "SERIES",
|
||||
title: "Zwischenstopp",
|
||||
tagline: "Zwanzig Minuten Aufenthalt.",
|
||||
overview:
|
||||
"Anthologieserie: jede Folge spielt vollständig auf einem anderen Bahnhof, in Echtzeit, zwischen zwei Zügen.",
|
||||
firstAirDate: "2026-05-14",
|
||||
status: "Returning Series",
|
||||
inProduction: true,
|
||||
genres: ["Drama", "Anthologie"],
|
||||
runtime: 22,
|
||||
voteAverage: 8.6,
|
||||
providers: [{ name: "Apple TV+", kind: "FLATRATE" }],
|
||||
seasons: [
|
||||
{
|
||||
seasonNumber: 1,
|
||||
name: "Staffel 1",
|
||||
airDate: "2026-05-14",
|
||||
runtime: 22,
|
||||
episodes: [
|
||||
"Hamm (Westf)",
|
||||
"Bad Bentheim",
|
||||
"Offenburg",
|
||||
"Bebra",
|
||||
"Neustadt (Schwarzwald)",
|
||||
"Probstzella",
|
||||
"Zoo Berlin",
|
||||
"Emmerich",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_MOVIES: DemoMovie[] = [
|
||||
// --- Sternenpfad-Reihe (Grundlage für das Beispiel-Universum) -------------
|
||||
{
|
||||
key: "sternenpfad-1",
|
||||
type: "MOVIE",
|
||||
title: "Sternenpfad",
|
||||
tagline: "Ein Kurs. Kein Zurück.",
|
||||
overview:
|
||||
"Eine Frachterpilotin nimmt einen Auftrag an, der sie über die Grenze der bekannten Karte hinausführt — und findet dort eine Flotte, die es nicht geben sollte.",
|
||||
releaseDate: "2016-12-15",
|
||||
runtime: 137,
|
||||
genres: ["Science Fiction", "Abenteuer"],
|
||||
voteAverage: 7.8,
|
||||
collection: "Sternenpfad-Reihe",
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-2",
|
||||
type: "MOVIE",
|
||||
title: "Sternenpfad II: Die stille Flotte",
|
||||
tagline: "Schweigen ist auch eine Antwort.",
|
||||
overview:
|
||||
"Zwei Jahre später: die Flotte antwortet nicht mehr. Eine Delegation soll herausfinden, warum — und findet eine Antwort, die niemand hören will.",
|
||||
releaseDate: "2019-07-04",
|
||||
runtime: 146,
|
||||
genres: ["Science Fiction", "Abenteuer", "Drama"],
|
||||
voteAverage: 8.2,
|
||||
collection: "Sternenpfad-Reihe",
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-3",
|
||||
type: "MOVIE",
|
||||
title: "Sternenpfad III: Heimkehr",
|
||||
tagline: "Manche Wege führen nur in eine Richtung.",
|
||||
overview:
|
||||
"Der Abschluss der ersten Trilogie. Die Rückkehr gelingt — aber nicht allen, und nicht dorthin, wo sie aufgebrochen sind.",
|
||||
releaseDate: "2021-12-16",
|
||||
runtime: 158,
|
||||
genres: ["Science Fiction", "Abenteuer"],
|
||||
voteAverage: 7.5,
|
||||
collection: "Sternenpfad-Reihe",
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-4",
|
||||
type: "MOVIE",
|
||||
title: "Sternenpfad IV: Die Kartografin",
|
||||
tagline: "Wer die Karte zeichnet, bestimmt den Rand.",
|
||||
overview:
|
||||
"Eine Generation später. Eine junge Kartografin zeichnet die Grenze neu — und macht damit aus einer Legende ein politisches Problem.",
|
||||
releaseDate: "2024-12-19",
|
||||
runtime: 149,
|
||||
genres: ["Science Fiction", "Drama"],
|
||||
voteAverage: 8.0,
|
||||
collection: "Sternenpfad-Reihe",
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-null",
|
||||
type: "MOVIE",
|
||||
title: "Sternenpfad: Null",
|
||||
tagline: "Bevor jemand hinaussah.",
|
||||
overview:
|
||||
"Vorgeschichte der Reihe: der Bau des ersten Fernaufklärers und die Entscheidung, die alles auslöste. Chronologisch der Anfang, im Kino der fünfte Teil.",
|
||||
releaseDate: "2026-07-16",
|
||||
runtime: 132,
|
||||
genres: ["Science Fiction", "Drama"],
|
||||
voteAverage: 7.1,
|
||||
collection: "Sternenpfad-Reihe",
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
},
|
||||
|
||||
// --- Einzelfilme ----------------------------------------------------------
|
||||
{
|
||||
key: "stille-bilder",
|
||||
type: "MOVIE",
|
||||
title: "Die Stille zwischen den Bildern",
|
||||
originalTitle: "The Silence Between Frames",
|
||||
tagline: "24 Bilder pro Sekunde. Eins davon lügt.",
|
||||
overview:
|
||||
"Ein Filmvorführer entdeckt in einer alten Rolle Bilder, die dort nicht sein können — und niemand außer ihm sieht sie.",
|
||||
releaseDate: "2024-10-17",
|
||||
runtime: 118,
|
||||
genres: ["Horror", "Thriller"],
|
||||
voteAverage: 7.4,
|
||||
collection: "Vorführer-Reihe",
|
||||
providers: [
|
||||
{ name: "WOW", kind: "FLATRATE" },
|
||||
{ name: "Google Play Movies", kind: "RENT" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "stille-bilder-2",
|
||||
type: "MOVIE",
|
||||
title: "Die Stille zwischen den Bildern II",
|
||||
tagline: "Diesmal sieht es zurück.",
|
||||
overview: "Fortsetzung: dieselbe Rolle, ein anderes Kino, dreißig Jahre später.",
|
||||
releaseDate: "2026-02-06",
|
||||
runtime: 126,
|
||||
genres: ["Horror", "Thriller"],
|
||||
voteAverage: 6.9,
|
||||
collection: "Vorführer-Reihe",
|
||||
providers: [{ name: "Google Play Movies", kind: "RENT" }],
|
||||
},
|
||||
{
|
||||
key: "hochwasser",
|
||||
type: "MOVIE",
|
||||
title: "Hochwasser",
|
||||
tagline: "Das Wasser fragt nicht.",
|
||||
overview:
|
||||
"Drei Tage in einer Kleinstadt an der Ahr. Kammerspiel über Nachbarschaft, wenn alles gleichzeitig passiert.",
|
||||
releaseDate: "2023-08-24",
|
||||
runtime: 104,
|
||||
genres: ["Drama"],
|
||||
voteAverage: 8.3,
|
||||
providers: [{ name: "ARD Mediathek", kind: "FREE" }],
|
||||
},
|
||||
{
|
||||
key: "pfand",
|
||||
type: "MOVIE",
|
||||
title: "Pfand",
|
||||
tagline: "Fünfundzwanzig Cent Unterschied.",
|
||||
overview:
|
||||
"Komödie über zwei Rentner, die in Hamburg-Barmbek einen Automatenkrieg anzetteln — ohne es zu merken.",
|
||||
releaseDate: "2025-06-12",
|
||||
runtime: 96,
|
||||
genres: ["Komödie"],
|
||||
voteAverage: 7.0,
|
||||
providers: [{ name: "Joyn", kind: "ADS" }],
|
||||
},
|
||||
{
|
||||
key: "die-vermessung",
|
||||
type: "MOVIE",
|
||||
title: "Die Vermessung des Feierabends",
|
||||
tagline: "Achtzehn Uhr. Und dann?",
|
||||
overview:
|
||||
"Dokumentarfilm über vier Menschen und die Frage, was von einem Tag übrig bleibt, wenn die Arbeit vorbei ist.",
|
||||
releaseDate: "2022-04-07",
|
||||
runtime: 88,
|
||||
genres: ["Dokumentation"],
|
||||
voteAverage: 7.7,
|
||||
providers: [{ name: "ARTE", kind: "FREE" }],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_TITLES: DemoTitle[] = [...DEMO_SERIES, ...DEMO_MOVIES];
|
||||
|
||||
/** TMDB-IDs der Demo-Titel liegen bewusst weit oberhalb echter IDs. */
|
||||
export const DEMO_TMDB_BASE = 9_100_000;
|
||||
|
||||
export function demoTmdbId(key: string): number {
|
||||
const index = DEMO_TITLES.findIndex((title) => title.key === key);
|
||||
return DEMO_TMDB_BASE + (index >= 0 ? index : 999);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Erzeugt Poster und Hintergrundbilder für die Demo-Titel.
|
||||
*
|
||||
* Warum überhaupt: 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 (Rechte, und es gäbe sie ohne
|
||||
* Schlüssel gar nicht), also entstehen hier abstrakte Poster im Flimmer-Look —
|
||||
* warmes Glühen auf dunklem Grund, Titel darauf.
|
||||
*
|
||||
* Die Dateien landen direkt im Bild-Cache unter demselben Namen, den
|
||||
* `src/lib/images.ts` berechnen würde. Damit funktioniert die Auslieferung über
|
||||
* /api/image ohne jede Sonderbehandlung.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
/** Muss zu `cacheFile()` in src/lib/images.ts passen. */
|
||||
function cacheFileName(size: string, path: string): string {
|
||||
const key = createHash("sha256").update(`${size}${path}`).digest("hex");
|
||||
return `${key}${path.slice(path.lastIndexOf("."))}`;
|
||||
}
|
||||
|
||||
/** Deterministischer Farbton je Titel — derselbe Titel bekommt immer dasselbe Poster. */
|
||||
function hue(seed: string): number {
|
||||
const hash = createHash("sha256").update(seed).digest();
|
||||
return (hash[0]! << 8 | hash[1]!) % 360;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Bricht den Titel auf mehrere Zeilen um, damit er auf das Poster passt. */
|
||||
function wrap(text: string, maxChars: number, maxLines: number): string[] {
|
||||
const words = text.split(/\s+/);
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
|
||||
for (const word of words) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (candidate.length <= maxChars) {
|
||||
current = candidate;
|
||||
} else {
|
||||
if (current) lines.push(current);
|
||||
current = word;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
|
||||
if (lines.length > maxLines) {
|
||||
const kept = lines.slice(0, maxLines);
|
||||
kept[maxLines - 1] = `${kept[maxLines - 1]!.slice(0, maxChars - 1)}…`;
|
||||
return kept;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
type PosterInput = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
seed: string;
|
||||
};
|
||||
|
||||
function posterSvg({ title, subtitle, seed }: PosterInput, width: number, height: number): string {
|
||||
const base = hue(seed);
|
||||
const warm = `hsl(${(base + 20) % 360} 92% 62%)`;
|
||||
const cool = `hsl(${(base + 320) % 360} 88% 58%)`;
|
||||
const scale = width / 342;
|
||||
|
||||
const fontSize = Math.round(30 * scale);
|
||||
const lines = wrap(title, 17, 3);
|
||||
const blockBottom = height - Math.round(52 * scale);
|
||||
const firstLineY = blockBottom - (lines.length - 1) * Math.round(fontSize * 1.14);
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="${width}" y2="${height}" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#171227"/>
|
||||
<stop offset="1" stop-color="#0a0810"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="${width * 0.72}" cy="${height * 0.24}" r="${width * 0.85}" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="${warm}" stop-opacity="0.85"/>
|
||||
<stop offset="0.45" stop-color="${cool}" stop-opacity="0.32"/>
|
||||
<stop offset="1" stop-color="${cool}" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shade" x1="0" y1="${height * 0.42}" x2="0" y2="${height}" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0a0810" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#0a0810" stop-opacity="0.92"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rule" x1="0" y1="0" x2="${width}" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="${warm}"/>
|
||||
<stop offset="1" stop-color="${cool}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="${width}" height="${height}" fill="url(#bg)"/>
|
||||
<rect width="${width}" height="${height}" fill="url(#glow)"/>
|
||||
|
||||
<g opacity="0.16" stroke="${warm}" fill="none" stroke-width="${1.4 * scale}">
|
||||
${Array.from({ length: 7 }, (_, index) => {
|
||||
const r = (0.18 + index * 0.13) * width;
|
||||
return `<circle cx="${width * 0.72}" cy="${height * 0.26}" r="${r.toFixed(1)}"/>`;
|
||||
}).join("\n ")}
|
||||
</g>
|
||||
|
||||
<rect width="${width}" height="${height}" fill="url(#shade)"/>
|
||||
<rect x="${24 * scale}" y="${blockBottom - fontSize * lines.length - 22 * scale}" width="${44 * scale}" height="${3 * scale}" rx="${1.5 * scale}" fill="url(#rule)"/>
|
||||
|
||||
<text x="${24 * scale}" y="${firstLineY}" font-family="Segoe UI, Inter, Helvetica, Arial, sans-serif" font-size="${fontSize}" font-weight="700" fill="#f4f1fa">
|
||||
${lines
|
||||
.map(
|
||||
(line, index) =>
|
||||
`<tspan x="${24 * scale}" dy="${index === 0 ? 0 : Math.round(fontSize * 1.14)}">${escapeXml(line)}</tspan>`,
|
||||
)
|
||||
.join("\n ")}
|
||||
</text>
|
||||
<text x="${24 * scale}" y="${blockBottom + Math.round(26 * scale)}" font-family="Segoe UI, Inter, Helvetica, Arial, sans-serif" font-size="${Math.round(15 * scale)}" fill="#aca5c2">${escapeXml(subtitle)}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function backdropSvg({ seed }: PosterInput, width: number, height: number): string {
|
||||
const base = hue(seed);
|
||||
const warm = `hsl(${(base + 20) % 360} 92% 62%)`;
|
||||
const cool = `hsl(${(base + 320) % 360} 88% 58%)`;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="${width}" y2="${height}" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#171227"/>
|
||||
<stop offset="1" stop-color="#0a0810"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="${width * 0.3}" cy="${height * 0.2}" r="${width * 0.7}" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="${warm}" stop-opacity="0.7"/>
|
||||
<stop offset="0.5" stop-color="${cool}" stop-opacity="0.28"/>
|
||||
<stop offset="1" stop-color="${cool}" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="${width}" height="${height}" fill="url(#bg)"/>
|
||||
<rect width="${width}" height="${height}" fill="url(#glow)"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/** Größen, die das UI anfragt. */
|
||||
const POSTER_SIZES: { size: string; width: number }[] = [
|
||||
{ size: "w92", width: 92 },
|
||||
{ size: "w154", width: 154 },
|
||||
{ size: "w185", width: 185 },
|
||||
{ size: "w342", width: 342 },
|
||||
{ size: "w500", width: 500 },
|
||||
];
|
||||
|
||||
const BACKDROP_SIZES: { size: string; width: number }[] = [
|
||||
{ size: "w780", width: 780 },
|
||||
{ size: "w1280", width: 1280 },
|
||||
];
|
||||
|
||||
export type GeneratedArt = { posterPath: string; backdropPath: string };
|
||||
|
||||
/**
|
||||
* Erzeugt Poster und Backdrop für einen Titel und legt sie im Bild-Cache ab.
|
||||
* Zurück kommen die TMDB-artigen Pfade, die in der Datenbank landen.
|
||||
*/
|
||||
export async function generateArtwork(
|
||||
dataDir: string,
|
||||
input: PosterInput,
|
||||
): Promise<GeneratedArt> {
|
||||
const stem = createHash("sha256").update(input.seed).digest("hex").slice(0, 24);
|
||||
const posterPath = `/${stem}p.jpg`;
|
||||
const backdropPath = `/${stem}b.jpg`;
|
||||
|
||||
const dir = join(dataDir, "images");
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
for (const { size, width } of POSTER_SIZES) {
|
||||
const height = Math.round(width * 1.5);
|
||||
const svg = posterSvg(input, width, height);
|
||||
const buffer = await sharp(Buffer.from(svg)).jpeg({ quality: 88 }).toBuffer();
|
||||
await writeFile(join(dir, cacheFileName(size, posterPath)), buffer);
|
||||
}
|
||||
|
||||
for (const { size, width } of BACKDROP_SIZES) {
|
||||
const height = Math.round((width * 9) / 16);
|
||||
const svg = backdropSvg(input, width, height);
|
||||
const buffer = await sharp(Buffer.from(svg)).jpeg({ quality: 84 }).toBuffer();
|
||||
await writeFile(join(dir, cacheFileName(size, backdropPath)), buffer);
|
||||
}
|
||||
|
||||
return { posterPath, backdropPath };
|
||||
}
|
||||
+465
-11
@@ -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();
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Seed: lege Demo-Konten an ...");
|
||||
const { admin, friend } = await seedUsers();
|
||||
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