12 Commits
Author SHA1 Message Date
Florian Wilde e2565b600f docs: Changelog, Release-Notes und DEVLOG für 0.3.0 2026-09-01 19:24:53 +02:00
Florian Wilde a3c74c157b fix: Build-Absturz durch zu viele Worker
Mit einem Build-Worker je Kern stürzte das Einsammeln der Seitendaten auf
einer 18-Kern-Maschine reproduzierbar ab (0xC0000409) — jeder Worker zieht
eine eigene Prisma-Engine hoch. Vier Worker sind schnell genug und laufen
zuverlässig.
2026-09-01 19:24:52 +02:00
Florian Wilde 6f5bdcc20c 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.
2026-09-01 19:24:52 +02:00
Florian Wilde f9ce2f7021 feat: Bibliotheksseite, Papierkorb und manuelles Anlegen
Die Detailseite hängt jetzt an der internen Titel-ID statt an der TMDB-ID —
nur so bekommen manuell angelegte Titel dieselbe Seite. Treffer aus der Suche
laufen über /tmdb/<art>/<id>, das den Titel holt und weiterleitet.

Der Fortschritt einer ganzen Liste wird in einer Abfrage gezählt statt je
Eintrag; bei 300 Titeln wären das sonst 300 Rundreisen zur Datenbank.

Entfernte Titel wandern für 30 Tage in den Papierkorb und behalten ihren
Fortschritt.
2026-09-01 19:24:51 +02:00
Florian Wilde f613136e8a feat: Bedienelemente für Bibliothek und Episodenfortschritt
Server-Aktionen prüfen die Sitzung jeweils selbst — auf Angaben aus dem
Formular ist beim Zugriffsschutz kein Verlass.

Häkchen reagieren sofort und werden zurückgedreht, wenn der Server
widerspricht: Abhaken soll sich anfühlen wie ein Schalter, nicht wie ein
Formular. Jede Sichtungsaktion meldet sich unten kurz zurück und lässt sich
von dort zurücknehmen — die Wirkung passiert weit weg vom Klick.
2026-09-01 19:24:51 +02:00
Florian Wilde 4c3c878980 test: Kern-Tracking absichern
14 Tests für Aufnehmen, Abhaken, Fortschritt ohne Specials, Rewatch,
Papierkorb, Rückgängig und den Zugriffsschutz zwischen Nutzern.

Die Testdateien laufen jetzt seriell: parallel räumten sie sich gegenseitig
die gemeinsamen Katalog-Fixtures weg.
2026-09-01 19:24:50 +02:00
Florian Wilde bb6ce4ab35 feat: Kern-Tracking mit Sichtungen, Fortschritt und Rewatches
Grundgedanke: Eine Sichtung ist die einzige Wahrheit darüber, was jemand
gesehen hat. Fortschritt, „nächste Episode" und später die Statistik werden
daraus berechnet, nichts davon wird doppelt gespeichert.

Rewatches zählen über einen Durchlaufzähler am Eintrag. Ein neuer Durchlauf
setzt den Fortschritt zurück, die alten Sichtungen bleiben stehen — sonst
verlöre die Schauzeit-Statistik jedes Mal ihre Geschichte.

Der Status ergibt sich aus den Sichtungen: die erste Episode startet, die
letzte schließt ab. Pausiert und Abgebrochen bleiben unangetastet — wer
bewusst pausiert hat, will das nicht durch ein Häkchen aufgehoben bekommen.
2026-09-01 19:24:49 +02:00
Florian Wilde 9ecaaf03a9 docs: Changelog, Release-Notes und DEVLOG für 0.2.0 2026-09-01 19:00:14 +02:00
Florian Wilde 75128d1bf2 test: Importpfad gegen nachgebildete TMDB-Antworten prüfen
npm test läuft ohne TMDB-Schlüssel gegen die Entwicklungsdatenbank und deckt
Kopfdaten, Staffeln, Episoden, Streamingdienste je Region, wiederholten
Import und die Auffrischlogik ab.

Dabei aufgefallen: importSeries gab den Titel zurück, bevor die Staffel- und
Episodenzähler geschrieben waren — die Zähler standen auf 0.
2026-09-01 19:00:13 +02:00
Florian Wilde e11cb6abef feat: Suchseite und Titel-Detailseite
Die Suchanfrage steht in der URL: Treffer sind teilbar, der Zurück-Knopf
funktioniert, und die Suche selbst bleibt eine Server-Komponente.

Posterkacheln zeigen bei fehlendem Bild die Bildmarke über dem
Signaturverlauf. Die Rückfalldarstellung liegt dauerhaft unter dem Bild —
ein onError-Handler hilft nicht, wenn das Bild schon vor der Hydration
scheitert, und der Browser zeigt dann sein kaputtes Bildsymbol.
2026-09-01 19:00:13 +02:00
Florian Wilde cc0e498fe6 feat: Katalogschicht und lokaler Bild-Cache
Der Katalog ist bewusst gemeinsam für alle Nutzer und über (Typ, TMDB-ID)
dedupliziert: zehn Leute mit derselben Serie brauchen die Episodenliste nur
einmal. Persönliches steckt ausschließlich in LibraryEntry und Watch.

Poster laufen über /api/image statt direkt zu TMDB — einmal holen, danach
vom eigenen Server ausliefern. Größe und Pfad werden streng geprüft, damit
daraus kein offener Proxy wird.

Staffel 0 (Specials) wird importiert, zählt aber nicht zum Fortschritt;
Episoden ohne eigene Laufzeit fallen auf die durchschnittliche Episodenlänge
der Serie zurück.
2026-09-01 19:00:12 +02:00
Florian Wilde 4feb84c060 feat: TMDB-Client für Suche, Details und Episodendaten
Nur die Endpunkte, die Flimmer wirklich braucht, samt der Felder, die uns
interessieren. Antworten werden zehn Minuten zwischengespeichert, damit
Tippen im Suchfeld nicht jedes Zeichen einzeln nach draußen trägt.

Zugang wahlweise über den klassischen API-Schlüssel oder das Bearer-Token —
TMDB gibt beides aus, und Selbsthoster haben mal das eine, mal das andere.
2026-09-01 19:00:11 +02:00
43 changed files with 6684 additions and 18 deletions
+89 -1
View File
@@ -7,6 +7,92 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
## [0.3.0] — 2026-09-01
Das Kernstück: Bibliothek, Status und Episodenfortschritt.
### Hinzugefügt
- **Bibliothek** als Posterraster mit Volltextsuche und Filtern nach Status,
Typ, Genre, Streamingdienst und Tag; Sortierung nach zuletzt aktualisiert,
hinzugefügt, Titel, Bewertung oder Erscheinungsjahr. Der gesamte Filterstand
steht in der URL und ist damit teilbar.
- **Fünf Status** je Eintrag (Schaue gerade, Pausiert, Abgeschlossen,
Abgebrochen, Watchlist). Der Status ergibt sich beim Abhaken von selbst:
die erste Episode startet einen Titel, die letzte schließt ihn ab.
Pausiert und Abgebrochen bleiben unangetastet — wer bewusst pausiert hat,
will das nicht durch eine Sichtung aufgehoben bekommen.
- **Episodenfortschritt**: einzeln abhaken, ganze Staffel auf einmal,
„bis hierhin gesehen" für den Einstieg mitten in einer Staffel, und ein
**„+1 Episode"**-Knopf, der genau die nächste offene Episode nimmt.
Häkchen reagieren sofort und werden zurückgedreht, wenn der Server
widerspricht; beim Abhaken glimmt die Zeile einmal warm auf.
- **„Als Nächstes dran"** mit Episodencode, Titel und Ausstrahlungsdatum;
Fortschritt in Prozent auf Basis der echten Episodenzahl, dazu die
verbleibende Laufzeit.
- **Rewatch-Zählung**: ein neuer Durchlauf setzt den Fortschritt zurück, die
Sichtungen des vorherigen bleiben für die Statistik erhalten. Bei Filmen
zählt jeder Durchlauf einzeln.
- **Rückgängig** für jede Sichtungsaktion — die Rückmeldung am unteren Rand
nennt, was passiert ist, und nimmt es auf Klick zurück.
- **Pro Eintrag**: Bewertung von 1 bis 10, Notizfeld, eigene Tags und das Feld
„wo schaue ich das", das beim Aufnehmen einen Vorschlag aus der
Streaming-Verfügbarkeit erhält und jederzeit überschreibbar ist.
- **Papierkorb** statt hartem Löschen: entfernte Titel bleiben 30 Tage liegen
und behalten dabei ihren Fortschritt. Wird ein Titel erneut aufgenommen,
lebt derselbe Eintrag wieder auf.
- **Titel selbst anlegen** für alles, was TMDB nicht kennt. Solche Einträge
haben keine TMDB-ID und werden vom Abgleich nie angefasst.
- **Seed mit Beispielbibliothek**: erfundener Katalog aus 8 Serien und
10 Filmen samt lokal erzeugten Postern, dazu 18 Bibliothekseinträge und
85 Sichtungen über die letzten Monate verteilt.
- **21 Tests** decken Aufnehmen, Abhaken, Fortschritt, Rewatch, Papierkorb und
den Zugriffsschutz zwischen Nutzern ab.
### Geändert
- Die Detailseite liegt jetzt unter `/titel/<id>` und hängt an der internen
Titel-ID statt an der TMDB-ID — nur so bekommen manuell angelegte Titel
dieselbe Seite. Treffer aus der Suche laufen über `/tmdb/<art>/<id>`, das
den Titel holt und weiterleitet.
- Die Zahl der Build-Worker ist auf vier begrenzt: mit einem Worker je Kern
stürzte der Build auf einer 18-Kern-Maschine reproduzierbar ab, weil jeder
Worker eine eigene Prisma-Engine hochzieht.
## [0.2.0] — 2026-09-01
Metadaten: Flimmer spricht mit TMDB.
### Hinzugefügt
- **TMDB-Anbindung** für Suche (gemeinsam über Serien und Filme oder getrennt),
Detailabruf, Staffeln und Episoden, offizielle Collections sowie die
Streaming-Verfügbarkeit in der eingestellten Region. Zugang wahlweise über
den klassischen API-Schlüssel oder das Bearer-Token.
- **Gemeinsamer Katalog**: Titel werden über (Typ, TMDB-ID) dedupliziert — bei
mehreren Nutzern liegt jede Serie samt Episodenliste nur einmal in der
Datenbank. `ensureTitle` holt einen Titel nur, wenn er fehlt oder veraltet ist,
und fällt bei einem TMDB-Ausfall auf den lokalen Stand zurück.
- **Bild-Cache**: Poster und Hintergrundbilder werden einmal geholt und danach
über `/api/image` vom eigenen Server ausgeliefert. Größe und Pfad werden
streng geprüft, damit der Endpunkt kein offener Proxy wird.
- **Suchseite** mit Typfilter; die Anfrage steht in der URL und ist damit
teilbar. Getippte Eingaben werden kurz gesammelt, bevor gesucht wird.
- **Detailseite** je Titel mit Backdrop, Kopfdaten, Genres, Streamingdiensten
(mit den Akzentfarben der großen Anbieter) und aufklappbaren Staffeln samt
Episodenliste.
- **Testlauf ohne Schlüssel**: nachgebildete TMDB-Antworten unter `tests/` und
`npm test` prüfen den kompletten Importpfad gegen eine echte Datenbank.
### Behoben
- `importSeries` gab den Titel zurück, bevor Staffel- und Episodenzähler
geschrieben waren — die Zähler standen dadurch auf 0.
- Poster und Dienstlogos zeigten das kaputte Bildsymbol des Browsers, wenn ein
TMDB-Pfad ins Leere lief. Die Rückfalldarstellung liegt jetzt dauerhaft
darunter statt an einem `onError`-Handler zu hängen, der bei einem früh
gescheiterten Bild gar nicht mehr feuert.
## [0.1.0] — 2026-09-01
Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
@@ -46,5 +132,7 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
dass weitere Sprachen als zusätzliche Datei dazukommen können.
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.1.0...HEAD
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.3.0...HEAD
[0.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0
[0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0
[0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0
+20 -1
View File
@@ -80,9 +80,28 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
- [ ] Optional per ENV: OIDC/SSO (Authelia, Keycloak)
- [ ] Registrierung durch Admin abschaltbar; erster Nutzer wird Admin
## Wichtige technische Merkposten
- **Next 16**: Request-APIs sind async (`cookies()`, `headers()`, `params`,
`searchParams`). `middleware.ts` heißt jetzt `proxy.ts`. Turbopack ist Standard.
Breaking Changes stehen in `node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md`.
- **Prisma 7**: keine `url` im Schema — Treiber-Adapter (`@prisma/adapter-pg`)
im Client, URL in `prisma.config.mjs`. Generator ist `prisma-client`
(TypeScript-Ausgabe nach `src/generated/prisma`, gitignored).
Config bewusst `.mjs`, damit die CLI im Produktionsimage ohne TS-Loader läuft.
- **Tailwind v4**: `@theme inline` reicht die `--fl-*`-Variablen durch,
`@custom-variant dark` hängt an `[data-theme="dark"]`.
- **Zwei Verläufe**: `--fl-gradient` für Flächen, `--fl-gradient-ink` für Text
(im Light Mode abgedunkelt, sonst kein AA). Logo-Stops `--fl-brand-1/2/3`.
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`.
- Next hängt selbst einen Block an diese Datei an — der bleibt drin.
## Fortschritt
- Stand: **Etappe 1 läuft** (Start 2026-09-01 18:09)
- ✅ **Etappe 1** (v0.1.0, released) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
- ⏳ **Etappe 2** — TMDB-Anbindung
<!-- BEGIN:nextjs-agent-rules -->
+31 -1
View File
@@ -21,10 +21,40 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
---
## Etappe 2 — TMDB-Anbindung (v0.2.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 2.1 | 18:47 | 18:51 | 4 min | TMDB-Client: Suche, Serien-, Film- und Staffelabruf, Collections, Anbieterlisten; kurzer Antwortspeicher |
| 2.2 | 18:51 | 18:54 | 3 min | Katalogschicht: Import von Titeln, Staffeln, Episoden und Streamingdiensten; Bild-Cache mit eigenem Ausliefer-Endpunkt |
| 2.3 | 18:54 | 18:57 | 3 min | Oberfläche: Suchseite mit Typfilter, Detailseite mit Staffeln, Poster- und Anbieterkomponenten |
| 2.4 | 18:57 | 19:01 | 4 min | Nachgebildete TMDB-Antworten und Testlauf (7 Tests); dabei zwei Fehler gefunden und behoben: veraltete Zähler nach dem Serienimport, kaputtes Bildsymbol statt Rückfallkachel |
**Etappe 2 gesamt: 14 min** (2026-09-01, 18:47 – 19:01)
---
## Etappe 3 — Kern-Tracking: Bibliothek, Status, Episodenfortschritt (v0.3.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 3.1 | 19:01 | 19:06 | 5 min | Domänenlogik: Aufnehmen, Status, Sichtungen, Rewatch-Durchläufe, Fortschritt, nächste Episode |
| 3.2 | 19:06 | 19:10 | 4 min | 14 Tests dazu; dabei ein Fehler im parallelen Testlauf gefunden (Testdateien räumten sich gegenseitig die Fixtures weg) |
| 3.3 | 19:10 | 19:14 | 4 min | Server-Aktionen, Rückmeldungen mit Rückgängig, Bedienelemente (Status, Bewertung, Notizen, Tags, Episodenliste, „+1 Episode") |
| 3.4 | 19:14 | 19:17 | 3 min | Bibliotheksseite mit Filtern und Facetten, Papierkorb, Umbau der Detailseite auf die interne Titel-ID |
| 3.5 | 19:17 | 19:23 | 6 min | Beispieldaten: erfundener Katalog mit lokal erzeugten Postern, gelebte Bibliothek mit Sichtungshistorie; Klickweg im Browser geprüft (+1 Episode, Rückgängig) |
| 3.6 | 19:23 | 19:25 | 2 min | Manuelles Anlegen ohne TMDB-Treffer; Build-Absturz durch zu viele Worker eingegrenzt und behoben |
**Etappe 3 gesamt: 24 min** (2026-09-01, 19:01 – 19:25)
---
## Gesamtübersicht
| Etappe | Version | Dauer |
|---|---|---|
| 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min |
| 2 — TMDB-Anbindung | 0.2.0 | 14 min |
| 3 — Kern-Tracking | 0.3.0 | 24 min |
**Bisher gesamt: 38 min**
**Bisher gesamt: 76 min**
+8
View File
@@ -4,6 +4,14 @@ const nextConfig: NextConfig = {
// Schlankes Docker-Image: Next kopiert nur die wirklich benötigten Dateien.
output: "standalone",
reactStrictMode: true,
experimental: {
// Begrenzte Zahl an Build-Workern. Mit der Voreinstellung (ein Worker je
// Kern) stürzte der Build auf einer 18-Kern-Maschine beim Einsammeln der
// Seitendaten reproduzierbar ab (Windows-Fehler 0xC0000409); jeder Worker
// zieht eine eigene Prisma-Engine hoch. Vier Worker sind schnell genug und
// laufen zuverlässig.
cpus: 4,
},
poweredByHeader: false,
serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"],
// Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "flimmer",
"version": "0.1.0",
"version": "0.3.0",
"private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT",
@@ -15,7 +15,8 @@
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"icons": "tsx scripts/generate-icons.ts",
"screenshots": "tsx scripts/screenshots.ts"
"screenshots": "tsx scripts/screenshots.ts",
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts"
},
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
+444
View File
@@ -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);
}
+195
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** 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 };
}
+466 -12
View File
@@ -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}`);
}
+33
View File
@@ -0,0 +1,33 @@
# Flimmer 0.2.0
Metadaten: Flimmer spricht mit TMDB.
### Hinzugefügt
- **TMDB-Anbindung** für Suche (gemeinsam über Serien und Filme oder getrennt),
Detailabruf, Staffeln und Episoden, offizielle Collections sowie die
Streaming-Verfügbarkeit in der eingestellten Region. Zugang wahlweise über
den klassischen API-Schlüssel oder das Bearer-Token.
- **Gemeinsamer Katalog**: Titel werden über (Typ, TMDB-ID) dedupliziert — bei
mehreren Nutzern liegt jede Serie samt Episodenliste nur einmal in der
Datenbank. `ensureTitle` holt einen Titel nur, wenn er fehlt oder veraltet ist,
und fällt bei einem TMDB-Ausfall auf den lokalen Stand zurück.
- **Bild-Cache**: Poster und Hintergrundbilder werden einmal geholt und danach
über `/api/image` vom eigenen Server ausgeliefert. Größe und Pfad werden
streng geprüft, damit der Endpunkt kein offener Proxy wird.
- **Suchseite** mit Typfilter; die Anfrage steht in der URL und ist damit
teilbar. Getippte Eingaben werden kurz gesammelt, bevor gesucht wird.
- **Detailseite** je Titel mit Backdrop, Kopfdaten, Genres, Streamingdiensten
(mit den Akzentfarben der großen Anbieter) und aufklappbaren Staffeln samt
Episodenliste.
- **Testlauf ohne Schlüssel**: nachgebildete TMDB-Antworten unter `tests/` und
`npm test` prüfen den kompletten Importpfad gegen eine echte Datenbank.
### Behoben
- `importSeries` gab den Titel zurück, bevor Staffel- und Episodenzähler
geschrieben waren — die Zähler standen dadurch auf 0.
- Poster und Dienstlogos zeigten das kaputte Bildsymbol des Browsers, wenn ein
TMDB-Pfad ins Leere lief. Die Rückfalldarstellung liegt jetzt dauerhaft
darunter statt an einem `onError`-Handler zu hängen, der bei einem früh
gescheiterten Bild gar nicht mehr feuert.
+51
View File
@@ -0,0 +1,51 @@
# Flimmer 0.3.0
Das Kernstück: Bibliothek, Status und Episodenfortschritt.
### Hinzugefügt
- **Bibliothek** als Posterraster mit Volltextsuche und Filtern nach Status,
Typ, Genre, Streamingdienst und Tag; Sortierung nach zuletzt aktualisiert,
hinzugefügt, Titel, Bewertung oder Erscheinungsjahr. Der gesamte Filterstand
steht in der URL und ist damit teilbar.
- **Fünf Status** je Eintrag (Schaue gerade, Pausiert, Abgeschlossen,
Abgebrochen, Watchlist). Der Status ergibt sich beim Abhaken von selbst:
die erste Episode startet einen Titel, die letzte schließt ihn ab.
Pausiert und Abgebrochen bleiben unangetastet — wer bewusst pausiert hat,
will das nicht durch eine Sichtung aufgehoben bekommen.
- **Episodenfortschritt**: einzeln abhaken, ganze Staffel auf einmal,
„bis hierhin gesehen" für den Einstieg mitten in einer Staffel, und ein
**„+1 Episode"**-Knopf, der genau die nächste offene Episode nimmt.
Häkchen reagieren sofort und werden zurückgedreht, wenn der Server
widerspricht; beim Abhaken glimmt die Zeile einmal warm auf.
- **„Als Nächstes dran"** mit Episodencode, Titel und Ausstrahlungsdatum;
Fortschritt in Prozent auf Basis der echten Episodenzahl, dazu die
verbleibende Laufzeit.
- **Rewatch-Zählung**: ein neuer Durchlauf setzt den Fortschritt zurück, die
Sichtungen des vorherigen bleiben für die Statistik erhalten. Bei Filmen
zählt jeder Durchlauf einzeln.
- **Rückgängig** für jede Sichtungsaktion — die Rückmeldung am unteren Rand
nennt, was passiert ist, und nimmt es auf Klick zurück.
- **Pro Eintrag**: Bewertung von 1 bis 10, Notizfeld, eigene Tags und das Feld
„wo schaue ich das", das beim Aufnehmen einen Vorschlag aus der
Streaming-Verfügbarkeit erhält und jederzeit überschreibbar ist.
- **Papierkorb** statt hartem Löschen: entfernte Titel bleiben 30 Tage liegen
und behalten dabei ihren Fortschritt. Wird ein Titel erneut aufgenommen,
lebt derselbe Eintrag wieder auf.
- **Titel selbst anlegen** für alles, was TMDB nicht kennt. Solche Einträge
haben keine TMDB-ID und werden vom Abgleich nie angefasst.
- **Seed mit Beispielbibliothek**: erfundener Katalog aus 8 Serien und
10 Filmen samt lokal erzeugten Postern, dazu 18 Bibliothekseinträge und
85 Sichtungen über die letzten Monate verteilt.
- **21 Tests** decken Aufnehmen, Abhaken, Fortschritt, Rewatch, Papierkorb und
den Zugriffsschutz zwischen Nutzern ab.
### Geändert
- Die Detailseite liegt jetzt unter `/titel/<id>` und hängt an der internen
Titel-ID statt an der TMDB-ID — nur so bekommen manuell angelegte Titel
dieselbe Seite. Treffer aus der Suche laufen über `/tmdb/<art>/<id>`, das
den Titel holt und weiterleitet.
- Die Zahl der Build-Worker ist auf vier begrenzt: mit einem Worker je Kern
stürzte der Build auf einer 18-Kern-Maschine reproduzierbar ab, weil jeder
Worker eine eigene Prisma-Engine hochzieht.
+285
View File
@@ -0,0 +1,285 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getSessionUser } from "@/lib/auth";
import {
addToLibrary,
LibraryError,
markEpisodesWatched,
markMovieWatched,
markSeasonWatched,
markUpTo,
moveToTrash,
purgeEntry,
restoreFromTrash,
setStatus,
startRewatch,
undoWatches,
unmarkEpisodes,
unmarkSeason,
updateEntryDetails,
watchNextEpisode,
type WatchResult,
} from "@/lib/library";
import { prisma } from "@/lib/db";
/**
* Server-Aktionen rund um die Bibliothek.
*
* Jede Aktion holt sich die Sitzung selbst — auf Angaben aus dem Formular ist
* beim Zugriffsschutz kein Verlass. Zurück kommt immer dieselbe Hülle, damit
* das UI Erfolg, Fehler und Rückgängig-Möglichkeit einheitlich behandeln kann.
*/
export type ActionResult = {
ok: boolean;
message?: string;
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
watchIds?: string[];
};
async function run(
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
revalidate: string[] = [],
): Promise<ActionResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
try {
const result = (await fn(user.id)) ?? {};
for (const path of revalidate) revalidatePath(path, "layout");
return { ok: true, message: result.message, watchIds: result.watchIds };
} catch (error) {
if (error instanceof LibraryError) return { ok: false, message: error.message };
console.error("Bibliotheksaktion fehlgeschlagen:", error);
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
}
}
const PATHS = ["/", "/bibliothek", "/titel", "/papierkorb", "/kalender", "/statistik"];
const cuid = z.string().min(1).max(64);
const statusSchema = z.enum(["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"]);
// ---------------------------------------------------------------------------
// Aufnehmen und entfernen
// ---------------------------------------------------------------------------
export async function addTitleAction(titleId: string, status: string = "WATCHLIST") {
return run(async (userId) => {
const parsedStatus = statusSchema.parse(status);
await addToLibrary(userId, cuid.parse(titleId), parsedStatus);
return {
message:
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
};
}, PATHS);
}
export async function trashEntryAction(entryId: string) {
return run(async (userId) => {
await moveToTrash(userId, cuid.parse(entryId));
return { message: "In den Papierkorb gelegt" };
}, PATHS);
}
export async function restoreEntryAction(entryId: string) {
return run(async (userId) => {
await restoreFromTrash(userId, cuid.parse(entryId));
return { message: "Wiederhergestellt" };
}, PATHS);
}
export async function purgeEntryAction(entryId: string) {
return run(async (userId) => {
await purgeEntry(userId, cuid.parse(entryId));
return { message: "Endgültig gelöscht" };
}, PATHS);
}
// ---------------------------------------------------------------------------
// Status und Angaben
// ---------------------------------------------------------------------------
export async function setStatusAction(entryId: string, status: string) {
return run(async (userId) => {
await setStatus(userId, cuid.parse(entryId), statusSchema.parse(status));
return { message: "Status geändert" };
}, PATHS);
}
const detailsSchema = z.object({
rating: z.number().int().min(1).max(10).nullable().optional(),
notes: z.string().max(5000).nullable().optional(),
whereToWatch: z.string().max(120).nullable().optional(),
favorite: z.boolean().optional(),
});
export async function updateEntryAction(entryId: string, data: unknown) {
return run(async (userId) => {
await updateEntryDetails(userId, cuid.parse(entryId), detailsSchema.parse(data));
return { message: "Gespeichert" };
}, PATHS);
}
export async function startRewatchAction(entryId: string) {
return run(async (userId) => {
const entry = await startRewatch(userId, cuid.parse(entryId));
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` };
}, PATHS);
}
// ---------------------------------------------------------------------------
// Sichtungen
// ---------------------------------------------------------------------------
function toResult(result: WatchResult) {
return { message: result.label, watchIds: result.watchIds };
}
export async function toggleEpisodeAction(entryId: string, episodeId: string, watched: boolean) {
return run(async (userId) => {
if (watched) {
return toResult(await markEpisodesWatched(userId, cuid.parse(entryId), [cuid.parse(episodeId)]));
}
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
return { message: "Markierung entfernt" };
}, PATHS);
}
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
return run(async (userId) => {
if (watched) {
return toResult(await markSeasonWatched(userId, cuid.parse(entryId), cuid.parse(seasonId)));
}
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
return { message: "Staffel zurückgesetzt" };
}, PATHS);
}
export async function markUpToAction(entryId: string, episodeId: string) {
return run(
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
PATHS,
);
}
export async function watchNextAction(entryId: string) {
return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS);
}
export async function markMovieAction(entryId: string) {
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
}
export async function undoAction(entryId: string, watchIds: string[]) {
return run(async (userId) => {
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
return { message: "Rückgängig gemacht" };
}, PATHS);
}
// ---------------------------------------------------------------------------
// Tags
// ---------------------------------------------------------------------------
export async function setTagsAction(entryId: string, tagNames: string[]) {
return run(async (userId) => {
const id = cuid.parse(entryId);
const names = z
.array(z.string().trim().min(1).max(40))
.max(20)
.parse(tagNames)
.map((name) => name.toLocaleLowerCase("de-DE"));
const entry = await prisma.libraryEntry.findFirst({
where: { id, userId },
select: { id: true },
});
if (!entry) throw new LibraryError("Dieser Eintrag gehört nicht zu deiner Bibliothek.");
const tags = await Promise.all(
names.map((name) =>
prisma.tag.upsert({
where: { userId_name: { userId, name } },
create: { userId, name },
update: {},
select: { id: true },
}),
),
);
await prisma.$transaction([
prisma.tagOnEntry.deleteMany({
where: { entryId: id, tagId: { notIn: tags.map((tag) => tag.id) } },
}),
...tags.map((tag) =>
prisma.tagOnEntry.upsert({
where: { tagId_entryId: { tagId: tag.id, entryId: id } },
create: { tagId: tag.id, entryId: id },
update: {},
}),
),
]);
return { message: "Tags gespeichert" };
}, PATHS);
}
// ---------------------------------------------------------------------------
// Manuell angelegte Titel
// ---------------------------------------------------------------------------
const manualSchema = z.object({
type: z.enum(["SERIES", "MOVIE"]),
title: z.string().trim().min(1).max(200),
year: z.number().int().min(1880).max(2200).nullable().optional(),
runtime: z.number().int().min(1).max(2000).nullable().optional(),
overview: z.string().trim().max(4000).optional(),
genres: z.array(z.string().trim().min(1).max(40)).max(10).optional(),
status: statusSchema.default("WATCHLIST"),
});
/**
* Legt einen Titel ohne TMDB-Treffer an.
*
* Für alles, was TMDB nicht kennt: Heimvideos, Vorträge, regionale
* Produktionen. Solche Titel haben keine `tmdbId` und werden vom Sync-Lauf
* daher nie angefasst.
*/
export async function createManualTitleAction(data: unknown): Promise<ActionResult & { titleId?: string }> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
const parsed = manualSchema.safeParse(data);
if (!parsed.success) {
return { ok: false, message: "Bitte gib mindestens einen Titel an." };
}
const { type, title, year, runtime, overview, genres, status } = parsed.data;
try {
const created = await prisma.title.create({
data: {
type,
title,
overview: overview || null,
runtime: runtime ?? null,
releaseDate: year ? new Date(Date.UTC(year, 0, 1)) : null,
genres: genres ?? [],
status: "Manuell angelegt",
syncedAt: new Date(),
},
select: { id: true },
});
await addToLibrary(user.id, created.id, status);
for (const path of PATHS) revalidatePath(path, "layout");
return { ok: true, message: `„${title}" angelegt`, titleId: created.id };
} catch (error) {
console.error("Manuellen Titel anlegen fehlgeschlagen:", error);
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getCachedImage, isValidImagePath, isValidSize } from "@/lib/images";
/**
* Ausliefern zwischengespeicherter TMDB-Bilder.
*
* /api/image?size=w342&path=/abc123.jpg
*
* Größe und Pfad werden streng geprüft — der Endpunkt soll kein offener Proxy
* für beliebige Adressen werden.
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const size = url.searchParams.get("size") ?? "w342";
const path = url.searchParams.get("path") ?? "";
if (!isValidSize(size) || !isValidImagePath(path)) {
return NextResponse.json({ error: "Ungültige Bildanfrage." }, { status: 400 });
}
try {
const image = await getCachedImage(size, path);
return new NextResponse(new Uint8Array(image.data), {
headers: {
"content-type": image.contentType,
// Bilder unter einem TMDB-Pfad ändern sich nicht mehr.
"cache-control": "public, max-age=31536000, immutable",
"x-flimmer-cache": image.fromCache ? "hit" : "miss",
},
});
} catch {
return NextResponse.json({ error: "Bild nicht verfügbar." }, { status: 404 });
}
}
+93
View File
@@ -0,0 +1,93 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type LibrarySort } from "@/lib/queries";
import { AppShell } from "@/components/AppShell";
import { LibraryCard } from "@/components/library/LibraryCard";
import { LibraryFilters } from "@/components/library/LibraryFilters";
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
export const metadata: Metadata = { title: "Bibliothek" };
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"];
const SORTS = ["aktualisiert", "hinzugefuegt", "titel", "bewertung", "jahr"];
export default async function LibraryPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
const user = await requireUser("/bibliothek");
const settings = await getSettings();
const params = await searchParams;
const filters: Filters = {
query: params.q?.trim() || undefined,
status: STATUSES.includes(params.status ?? "") ? (params.status as EntryStatus) : undefined,
type: params.typ === "SERIES" || params.typ === "MOVIE" ? (params.typ as TitleType) : undefined,
genre: params.genre || undefined,
tag: params.tag || undefined,
provider: params.dienst || undefined,
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
};
const [items, facets] = await Promise.all([
getLibrary(user.id, filters),
getLibraryFacets(user.id),
]);
const hasFilters = Object.values(filters).some(Boolean);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Bibliothek</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{facets.counts.ALLE === 0
? "Noch nichts aufgenommen."
: `${facets.counts.ALLE} ${facets.counts.ALLE === 1 ? "Titel" : "Titel"} — davon ${facets.counts.WATCHING} gerade am Laufen.`}
</p>
</div>
<Link href="/suche" className="fl-btn fl-btn-primary">
Titel hinzufügen
</Link>
</header>
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
<div className="mt-6">
{items.length === 0 ? (
<div className="fl-card p-8 text-center">
<p className="text-[var(--fl-muted)]">
{facets.counts.ALLE === 0
? "Deine Bibliothek ist noch leer."
: hasFilters
? "Zu dieser Auswahl passt nichts."
: "Hier ist nichts."}
</p>
{facets.counts.ALLE === 0 && (
<Link href="/suche" className="fl-btn fl-btn-primary mt-4">
Ersten Titel suchen
</Link>
)}
</div>
) : (
<>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Titel" : "Titel"}
</p>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((item) => (
<li key={item.entryId}>
<LibraryCard item={item} />
</li>
))}
</ul>
</>
)}
</div>
</AppShell>
);
}
+12
View File
@@ -332,6 +332,18 @@
animation: fl-glimmer 900ms var(--ease-glow);
}
/* Rückmeldungen fahren von unten herein — kurz und ohne Sprung. */
@keyframes fl-toast-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Outfit } from "next/font/google";
import { BrandDefs } from "@/components/brand/FlimmerLogo";
import { ToastProvider } from "@/components/Toasts";
import { env } from "@/lib/env";
import "./globals.css";
@@ -73,7 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
>
Zum Inhalt springen
</a>
{children}
<ToastProvider>{children}</ToastProvider>
</body>
</html>
);
+71
View File
@@ -0,0 +1,71 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { getTrash } from "@/lib/queries";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { TrashActions } from "@/components/library/TrashActions";
import { formatRelative, TYPE_LABEL } from "@/lib/labels";
export const metadata: Metadata = { title: "Papierkorb" };
/** Nach dieser Frist räumt der Hintergrundlauf endgültig auf. */
const RETENTION_DAYS = 30;
export default async function TrashPage() {
const user = await requireUser("/papierkorb");
const settings = await getSettings();
const entries = await getTrash(user.id);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Papierkorb</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Entfernte Titel bleiben {RETENTION_DAYS} Tage liegen. Danach werden sie samt ihrer
Sichtungen endgültig gelöscht.
</p>
</header>
{entries.length === 0 ? (
<div className="fl-card p-8 text-center">
<p className="text-[var(--fl-muted)]">Der Papierkorb ist leer.</p>
<Link href="/bibliothek" className="fl-btn fl-btn-ghost mt-4">
Zur Bibliothek
</Link>
</div>
) : (
<ul className="space-y-3">
{entries.map((entry) => {
const deletedAt = entry.deletedAt!;
const purgeAt = new Date(deletedAt.getTime() + RETENTION_DAYS * 86_400_000);
return (
<li key={entry.id} className="fl-card flex flex-wrap items-center gap-4 p-3 sm:p-4">
<div className="w-16 shrink-0">
<Poster path={entry.title.posterPath} alt={entry.title.title} size="w154" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">{entry.title.title}</p>
<p className="text-sm text-[var(--fl-faint)]">
{TYPE_LABEL[entry.title.type]}
{entry.title.releaseDate ? ` · ${entry.title.releaseDate.getFullYear()}` : ""}
{" · entfernt "}
{formatRelative(deletedAt)}
</p>
<p className="mt-0.5 text-xs text-[var(--fl-warning)]">
Wird {formatRelative(purgeAt)} endgültig gelöscht
</p>
</div>
<TrashActions entryId={entry.id} title={entry.title.title} />
</li>
);
})}
</ul>
)}
</AppShell>
);
}
+161
View File
@@ -0,0 +1,161 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { env } from "@/lib/env";
import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "@/lib/tmdb";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { SearchField } from "@/components/SearchField";
export const metadata: Metadata = { title: "Suchen" };
type Filter = "alle" | "serien" | "filme";
const FILTERS: { key: Filter; label: string }[] = [
{ key: "alle", label: "Alles" },
{ key: "serien", label: "Serien" },
{ key: "filme", label: "Filme" },
];
/** Normalisiert die drei TMDB-Antwortformen auf eine gemeinsame Darstellung. */
function normalize(item: TmdbSearchResult, fallbackType: "tv" | "movie") {
const type = item.media_type === "tv" || item.media_type === "movie" ? item.media_type : fallbackType;
const date = type === "tv" ? item.first_air_date : item.release_date;
return {
tmdbId: item.id,
type,
title: (type === "tv" ? item.name : item.title) ?? item.original_name ?? item.original_title ?? "Ohne Titel",
overview: item.overview ?? "",
posterPath: item.poster_path ?? null,
year: date ? date.slice(0, 4) : null,
rating: item.vote_average ?? null,
};
}
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; typ?: string }>;
}) {
const user = await requireUser("/suche");
const settings = await getSettings();
const params = await searchParams;
const query = (params.q ?? "").trim();
const filter: Filter = FILTERS.some((f) => f.key === params.typ) ? (params.typ as Filter) : "alle";
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Suchen</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Serien und Filme aus der TMDB-Datenbank — mit Postern, Staffeln und Episoden.
</p>
</header>
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
<p className="mt-3 text-sm text-[var(--fl-faint)]">
Nichts dabei?{" "}
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
Titel selbst anlegen
</Link>
</p>
<div className="mt-8">
{!env.tmdb.isConfigured ? (
<Notice tone="warning">
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "}
<code>.env</code> ein und starte die Instanz neu.
</Notice>
) : query.length < 2 ? (
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
) : (
<Results query={query} filter={filter} />
)}
</div>
</AppShell>
);
}
async function Results({ query, filter }: { query: string; filter: Filter }) {
let items: ReturnType<typeof normalize>[];
try {
if (filter === "serien") {
const data = await searchSeries(query);
items = data.results.map((item) => normalize(item, "tv"));
} else if (filter === "filme") {
const data = await searchMovies(query);
items = data.results.map((item) => normalize(item, "movie"));
} else {
const data = await searchMulti(query);
items = data.results.map((item) => normalize(item, "movie"));
}
} catch (error) {
return (
<Notice tone="danger">
{error instanceof Error ? error.message : "Die Suche bei TMDB ist fehlgeschlagen."}
</Notice>
);
}
if (items.length === 0) {
return <Notice>Zu „{query}" hat TMDB nichts gefunden.</Notice>;
}
return (
<>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Treffer" : "Treffer"} zu „{query}"
</p>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((item) => (
<li key={`${item.type}-${item.tmdbId}`}>
<Link
href={`/tmdb/${item.type === "tv" ? "serie" : "film"}/${item.tmdbId}`}
className="fl-poster-card group block focus:outline-none"
>
<Poster path={item.posterPath} alt={item.title} size="w342" />
<div className="mt-2 space-y-0.5">
<p className="line-clamp-2 text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
{item.title}
</p>
<p className="text-xs text-[var(--fl-faint)]">
{item.type === "tv" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
</p>
</div>
</Link>
</li>
))}
</ul>
</>
);
}
function Notice({
children,
tone = "neutral",
}: {
children: React.ReactNode;
tone?: "neutral" | "warning" | "danger";
}) {
const color =
tone === "warning" ? "var(--fl-warning)" : tone === "danger" ? "var(--fl-danger)" : "var(--fl-muted)";
return (
<p
className="fl-card px-4 py-3 text-sm"
style={{
color,
borderColor:
tone === "neutral" ? undefined : `color-mix(in oklab, ${color} 45%, transparent)`,
backgroundColor:
tone === "neutral" ? undefined : `color-mix(in oklab, ${color} 10%, transparent)`,
}}
>
{children}
</p>
);
}
+309
View File
@@ -0,0 +1,309 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { ensureTitle } from "@/lib/catalog";
import { getNextEpisode, getProgress, getWatchedEpisodeIds } from "@/lib/library";
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
import { AppShell } from "@/components/AppShell";
import { Backdrop, Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import {
ProviderList,
type ProviderBadgeData,
type ProviderKindKey,
} from "@/components/ProviderBadges";
import { SeasonList, type SeasonData } from "@/components/library/SeasonList";
import {
AddToLibrary,
EntryActions,
RatingPicker,
StatusPicker,
} from "@/components/library/EntryControls";
import { EntryNotes } from "@/components/library/EntryNotes";
import { NextEpisodeCard, WatchMovieButton } from "@/components/library/NextEpisode";
type Params = Promise<{ id: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { id } = await params;
const title = await prisma.title.findUnique({ where: { id }, select: { title: true } });
return { title: title?.title ?? "Titel" };
}
export default async function TitlePage({ params }: { params: Params }) {
const user = await requireUser();
const settings = await getSettings();
const { id } = await params;
const title = await prisma.title.findUnique({
where: { id },
include: {
seasons: {
orderBy: { seasonNumber: "asc" },
include: { episodes: { orderBy: { episodeNumber: "asc" } } },
},
providers: { where: { region: user.region }, include: { provider: true } },
entries: {
where: { userId: user.id },
include: { tags: { include: { tag: true } } },
},
},
});
if (!title) notFound();
// Im Hintergrund auffrischen, falls der Eintrag alt ist. Schlägt das fehl,
// bleibt der lokale Stand stehen — die Seite funktioniert trotzdem.
if (title.tmdbId) {
await ensureTitle(title.type, title.tmdbId, { region: user.region }).catch(() => undefined);
}
const entry = title.entries[0] ?? null;
const isSeries = title.type === "SERIES";
const [progress, next, watchedIds] = entry
? await Promise.all([
getProgress(user.id, entry.id),
isSeries ? getNextEpisode(user.id, entry.id) : Promise.resolve(null),
isSeries ? getWatchedEpisodeIds(user.id, entry.id) : Promise.resolve(new Set<string>()),
])
: [null, null, new Set<string>()];
const providerGroups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>> = {};
for (const row of title.providers) {
(providerGroups[row.kind as ProviderKindKey] ??= []).push({
id: row.provider.id,
name: row.provider.name,
logoPath: row.provider.logoPath,
color: row.provider.color,
});
}
const suggestion = providerGroups.FLATRATE?.[0]?.name ?? providerGroups.FREE?.[0]?.name ?? null;
const seasons: SeasonData[] = title.seasons.map((season) => ({
id: season.id,
seasonNumber: season.seasonNumber,
name: season.name,
overview: season.overview,
airDate: season.airDate?.toISOString() ?? null,
episodes: season.episodes.map((episode) => ({
id: episode.id,
episodeNumber: episode.episodeNumber,
name: episode.name,
overview: episode.overview,
airDate: episode.airDate?.toISOString() ?? null,
runtime: episode.runtime,
})),
}));
const year = title.releaseDate?.getFullYear() ?? null;
const movieWatched = !isSeries && (progress?.watched ?? 0) > 0;
return (
<AppShell user={user} instanceName={settings.instanceName}>
<article>
<div className="fl-card relative overflow-hidden">
<Backdrop path={title.backdropPath} alt="" />
<div className="relative flex flex-col gap-6 p-5 sm:flex-row sm:p-7">
<div className="w-36 shrink-0 sm:w-48">
<Poster path={title.posterPath} alt={title.title} size="w500" priority />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
{isSeries ? "Serie" : "Film"}
{year ? ` · ${year}` : ""}
{title.status ? ` · ${title.status}` : ""}
</p>
{entry && (
<span
className="rounded-full px-2.5 py-0.5 text-xs font-medium"
style={{
color: STATUS_COLOR[entry.status as StatusKey],
backgroundColor: `color-mix(in oklab, ${STATUS_COLOR[entry.status as StatusKey]} 16%, transparent)`,
}}
>
{STATUS_LABEL[entry.status as StatusKey]}
</span>
)}
</div>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">
{title.title}
</h1>
{title.originalTitle && title.originalTitle !== title.title && (
<p className="mt-0.5 text-sm text-[var(--fl-faint)]">{title.originalTitle}</p>
)}
{title.tagline && (
<p className="mt-2 text-sm italic text-[var(--fl-muted)]">„{title.tagline}"</p>
)}
<dl className="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
{isSeries && (
<Fact label="Umfang">
{title.seasonCount} {title.seasonCount === 1 ? "Staffel" : "Staffeln"} ·{" "}
{title.episodeCount} Episoden
</Fact>
)}
{title.runtime ? (
<Fact label={isSeries ? "Pro Episode" : "Laufzeit"}>{title.runtime} min</Fact>
) : null}
{title.voteAverage ? <Fact label="TMDB">{title.voteAverage.toFixed(1)} / 10</Fact> : null}
{entry && progress ? (
<Fact label="Deine Zeit">{formatMinutes(progress.minutesWatched)}</Fact>
) : null}
</dl>
{title.genres.length > 0 && (
<ul className="mt-4 flex flex-wrap gap-1.5">
{title.genres.map((genre) => (
<li
key={genre}
className="rounded-full border border-[var(--fl-border)] px-2.5 py-0.5 text-xs text-[var(--fl-muted)]"
>
{genre}
</li>
))}
</ul>
)}
{title.overview && (
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-[var(--fl-muted)]">
{title.overview}
</p>
)}
<div className="mt-6">
{entry ? (
<div className="space-y-4">
{progress && (
<div className="max-w-md">
<ProgressBar
percent={progress.percent}
label={
isSeries
? `${progress.watched} von ${progress.total} Episoden gesehen — noch ${formatMinutes(progress.minutesLeft)}`
: movieWatched
? "Gesehen"
: `Noch ${formatMinutes(progress.minutesLeft)}`
}
showValue
/>
</div>
)}
{isSeries && next && <NextEpisodeCard entryId={entry.id} next={{ ...next, airDate: next.airDate?.toISOString() ?? null }} />}
{isSeries && !next && progress?.total ? (
<p className="rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
Durchgeschaut — alle {progress.total} Episoden abgehakt.
</p>
) : null}
{!isSeries && <WatchMovieButton entryId={entry.id} watched={movieWatched} />}
<StatusPicker entryId={entry.id} status={entry.status as StatusKey} />
</div>
) : (
<AddToLibrary titleId={title.id} isSeries={isSeries} />
)}
</div>
</div>
</div>
</div>
{entry && (
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<section className="order-2 lg:order-1">
<h2 className="mb-3 text-lg font-semibold">
{isSeries ? "Staffeln" : "Wo läuft das?"}
</h2>
{isSeries ? (
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
) : (
<div className="fl-card p-4">
<ProviderList groups={providerGroups} region={user.region} />
</div>
)}
</section>
<aside className="order-1 space-y-6 lg:order-2">
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Deine Bewertung
</h2>
<RatingPicker entryId={entry.id} rating={entry.rating} />
</div>
<div className="fl-card p-4">
<EntryNotes
entryId={entry.id}
notes={entry.notes}
whereToWatch={entry.whereToWatch}
tags={entry.tags.map((link) => link.tag.name)}
suggestion={suggestion}
/>
</div>
{isSeries && (
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Wo läuft das?
</h2>
<ProviderList groups={providerGroups} region={user.region} />
</div>
)}
<div className="fl-card p-4">
<EntryActions
entryId={entry.id}
currentRun={entry.currentRun}
canRewatch={entry.status === "COMPLETED"}
/>
</div>
</aside>
</div>
)}
{!entry && (
<>
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Wo läuft das?</h2>
<div className="fl-card p-4">
<ProviderList groups={providerGroups} region={user.region} />
<p className="mt-3 text-xs text-[var(--fl-faint)]">
Verfügbarkeitsdaten von JustWatch über TMDB, Region {user.region}.
</p>
</div>
</section>
{isSeries && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Staffeln</h2>
<SeasonList seasons={seasons} />
</section>
)}
</>
)}
<p className="mt-8 text-sm">
<Link href="/bibliothek" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Zur Bibliothek
</Link>
</p>
</article>
</AppShell>
);
}
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
<dd className="mt-0.5 font-medium">{children}</dd>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { AppShell } from "@/components/AppShell";
import { ManualTitleForm } from "@/components/library/ManualTitleForm";
export const metadata: Metadata = { title: "Titel anlegen" };
export default async function NewTitlePage() {
const user = await requireUser("/titel/neu");
const settings = await getSettings();
return (
<AppShell user={user} instanceName={settings.instanceName}>
<div className="mx-auto max-w-2xl">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Titel selbst anlegen</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Für alles, was TMDB nicht kennt. Solche Titel werden vom Abgleich nie angefasst — die
Angaben bleiben so, wie du sie einträgst.
</p>
</header>
<div className="fl-card p-5 sm:p-6">
<ManualTitleForm />
</div>
<p className="mt-6 text-sm text-[var(--fl-faint)]">
Lieber doch bei TMDB nachsehen?{" "}
<Link href="/suche" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
Zur Suche
</Link>
</p>
</div>
</AppShell>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { notFound, redirect } from "next/navigation";
import { requireUser } from "@/lib/auth";
import { ensureTitle } from "@/lib/catalog";
import { TmdbError } from "@/lib/tmdb";
import type { TitleType } from "@/generated/prisma/client";
/**
* Einsprung von der Suche: holt den Titel bei TMDB und leitet auf die
* kanonische Detailseite weiter.
*
* Warum der Umweg: Die Detailseite hängt an der internen Titel-ID, damit auch
* manuell angelegte Titel ohne TMDB-Treffer dieselbe Seite bekommen. Vor dem
* ersten Import gibt es diese ID aber noch nicht.
*/
const ART_TO_TYPE: Record<string, TitleType> = { serie: "SERIES", film: "MOVIE" };
export default async function TmdbRedirectPage({
params,
}: {
params: Promise<{ art: string; tmdbId: string }>;
}) {
const user = await requireUser();
const { art, tmdbId: raw } = await params;
const type = ART_TO_TYPE[art];
const tmdbId = Number.parseInt(raw, 10);
if (!type || !Number.isFinite(tmdbId)) notFound();
let title;
try {
title = await ensureTitle(type, tmdbId, { region: user.region });
} catch (error) {
if (error instanceof TmdbError && error.status === 404) notFound();
throw error;
}
redirect(`/titel/${title.id}`);
}
+97
View File
@@ -0,0 +1,97 @@
import { clsx } from "clsx";
import { posterUrl, type ImageSize } from "@/lib/images";
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
/**
* Posterbild mit Rückfallebene.
*
* Nicht jeder Titel hat bei TMDB ein Poster, manuell angelegte schon gar
* nicht, und einzelne Pfade verschwinden mit der Zeit.
*
* Die Rückfallkachel liegt deshalb **dauerhaft darunter**, das Bild darüber:
* lädt es, verdeckt es die Kachel; lädt es nicht, bleibt die Kachel stehen.
* Bewusst ohne onError-Handler — ein Bild kann schon fehlschlagen, bevor React
* den Handler überhaupt angehängt hat. Das leere alt-Attribut sorgt dafür,
* dass ein kaputtes Bild in sich zusammenfällt statt ein Symbol zu zeigen;
* den Titel trägt der Container als aria-label.
*/
export function Poster({
path,
alt,
size = "w342",
className,
sizes = "(min-width: 1024px) 12rem, 40vw",
priority = false,
}: {
path: string | null | undefined;
alt: string;
size?: ImageSize;
className?: string;
sizes?: string;
priority?: boolean;
}) {
const url = posterUrl(path, size);
return (
<div
role="img"
aria-label={alt}
className={clsx(
"relative aspect-[2/3] w-full overflow-hidden rounded-[var(--radius-poster,0.875rem)] bg-[var(--fl-surface-2)]",
className,
)}
>
<div
aria-hidden
className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-3 text-center"
style={{ backgroundImage: "var(--fl-gradient-soft)" }}
>
<FlimmerMark className="h-8 w-8 opacity-70" flat />
<span className="line-clamp-3 text-xs text-[var(--fl-muted)]">{alt}</span>
</div>
{url && (
// Bewusst <img> statt next/image: die Bilder laufen bereits über den
// eigenen Cache-Endpunkt, eine zweite Optimierungsschicht brächte dem
// selbst gehosteten Server nur zusätzliche Last.
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt=""
sizes={sizes}
loading={priority ? "eager" : "lazy"}
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
/>
)}
</div>
);
}
/** Breitbild-Variante für Kopfbereiche (Backdrop). */
export function Backdrop({
path,
alt,
className,
}: {
path: string | null | undefined;
alt: string;
className?: string;
}) {
const url = posterUrl(path, "w1280");
if (!url) return null;
return (
<div className={clsx("absolute inset-0 overflow-hidden", className)} aria-hidden>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt="" className="h-full w-full object-cover opacity-25" />
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(to top, var(--fl-bg) 8%, color-mix(in oklab, var(--fl-bg) 70%, transparent) 55%, color-mix(in oklab, var(--fl-bg) 30%, transparent) 100%)",
}}
/>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { clsx } from "clsx";
/**
* Fortschrittsbalken im Signaturverlauf.
*
* Der Wert steht zusätzlich als Text daneben — ein Balken allein ist für
* Screenreader und bei starker Vergrößerung wertlos.
*/
export function ProgressBar({
percent,
label,
className,
showValue = false,
}: {
percent: number;
label: string;
className?: string;
showValue?: boolean;
}) {
const value = Math.max(0, Math.min(100, Math.round(percent)));
return (
<div className={clsx("space-y-1", className)}>
<div
className="fl-progress"
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
>
<span style={{ width: `${value}%` }} />
</div>
{showValue && <p className="text-xs text-[var(--fl-faint)]">{label}</p>}
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { posterUrl } from "@/lib/images";
/**
* Streamingdienste als Badges.
*
* Farbe und Logo kommen aus dem Katalog; Dienste ohne hinterlegte Akzentfarbe
* bekommen einen neutralen Ton, damit die Reihe trotzdem ruhig aussieht.
*/
export type ProviderBadgeData = {
id: string;
name: string;
logoPath: string | null;
color: string | null;
};
const KIND_LABEL = {
FLATRATE: "Im Abo",
FREE: "Kostenlos",
ADS: "Mit Werbung",
RENT: "Leihen",
BUY: "Kaufen",
} as const;
export type ProviderKindKey = keyof typeof KIND_LABEL;
export function ProviderBadge({ provider }: { provider: ProviderBadgeData }) {
const logo = posterUrl(provider.logoPath, "w92");
const accent = provider.color ?? "var(--fl-border-strong)";
return (
<span
className="inline-flex items-center gap-1.5 rounded-full border py-1 pl-1 pr-3 text-xs font-medium"
style={{
borderColor: `color-mix(in oklab, ${accent} 55%, transparent)`,
backgroundColor: `color-mix(in oklab, ${accent} 14%, transparent)`,
}}
>
{/* Farbpunkt liegt darunter; ein Logo, das nicht lädt, fällt in sich
zusammen und gibt ihn wieder frei. */}
<span className="relative h-5 w-5 shrink-0">
<span
aria-hidden
className="absolute inset-0 rounded-full"
style={{ backgroundColor: accent }}
/>
{logo && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo}
alt=""
loading="lazy"
decoding="async"
className="absolute inset-0 h-5 w-5 rounded-full object-cover"
/>
)}
</span>
<span className="text-[var(--fl-text)]">{provider.name}</span>
</span>
);
}
/** Alle Verfügbarkeiten eines Titels, nach Art gruppiert. */
export function ProviderList({
groups,
region,
}: {
groups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>>;
region: string;
}) {
const order: ProviderKindKey[] = ["FLATRATE", "FREE", "ADS", "RENT", "BUY"];
const present = order.filter((kind) => (groups[kind]?.length ?? 0) > 0);
if (present.length === 0) {
return (
<p className="text-sm text-[var(--fl-faint)]">
Für die Region {region} meldet TMDB derzeit keinen Streamingdienst.
</p>
);
}
return (
<div className="space-y-3">
{present.map((kind) => (
<div key={kind} className="flex flex-wrap items-center gap-2">
<span className="w-24 shrink-0 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
{KIND_LABEL[kind]}
</span>
{groups[kind]?.map((provider) => (
<ProviderBadge key={`${kind}-${provider.id}`} provider={provider} />
))}
</div>
))}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { usePathname, useRouter } from "next/navigation";
import { clsx } from "clsx";
/**
* Suchfeld mit Typfilter.
*
* Die Anfrage steht in der URL — damit sind Treffer teilbar, der Zurück-Knopf
* funktioniert und die Suche selbst bleibt eine Server-Komponente. Getippte
* Eingaben werden kurz gesammelt, bevor die Adresse fortgeschrieben wird.
*/
export function SearchField({
initialQuery,
filter,
filters,
}: {
initialQuery: string;
filter: string;
filters: { key: string; label: string }[];
}) {
const router = useRouter();
const pathname = usePathname();
const [value, setValue] = useState(initialQuery);
const [pending, startTransition] = useTransition();
const first = useRef(true);
useEffect(() => {
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
// gerade geladene URL durch eine identische.
if (first.current) {
first.current = false;
return;
}
const timer = setTimeout(() => {
const params = new URLSearchParams();
if (value.trim()) params.set("q", value.trim());
if (filter !== "alle") params.set("typ", filter);
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
});
}, 350);
return () => clearTimeout(timer);
}, [value, filter, pathname, router]);
function selectFilter(key: string) {
const params = new URLSearchParams();
if (value.trim()) params.set("q", value.trim());
if (key !== "alle") params.set("typ", key);
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
});
}
return (
<div className="space-y-3">
<div className="relative">
<SearchIcon />
<input
type="search"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Serie oder Film suchen …"
aria-label="Suchbegriff"
autoFocus
className="fl-input pl-10 text-base"
/>
{pending && (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
)}
</div>
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
{filters.map((item) => (
<button
key={item.key}
type="button"
onClick={() => selectFilter(item.key)}
aria-pressed={filter === item.key}
className={clsx(
"rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
filter === item.key
? "border-transparent text-[var(--fl-on-accent)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={filter === item.key ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{item.label}
</button>
))}
</div>
</div>
);
}
function SearchIcon() {
return (
<svg
viewBox="0 0 24 24"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--fl-faint)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
</svg>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { clsx } from "clsx";
import type { ActionResult } from "@/app/actions/library";
/**
* Kurze Rückmeldungen am unteren Rand — mit optionalem „Rückgängig".
*
* Warum überhaupt: Beim Abhaken einer Episode passiert die eigentliche Wirkung
* weit weg vom Klick (Fortschrittsbalken, Status, „nächste Episode"). Eine
* kurze Bestätigung sagt, was gerade geschehen ist — und lässt es zurücknehmen,
* ohne die Stelle wiederfinden zu müssen.
*/
type Toast = {
id: number;
message: string;
tone: "info" | "error";
undo?: () => Promise<void> | void;
};
type ToastContext = {
show(message: string, options?: { tone?: Toast["tone"]; undo?: Toast["undo"] }): void;
/** Bequemer Weg für Aktionsergebnisse samt Rückgängig-Möglichkeit. */
report(result: ActionResult, undo?: (watchIds: string[]) => Promise<unknown>): void;
};
const Context = createContext<ToastContext | null>(null);
const VISIBLE_MS = 6000;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const dismiss = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const show = useCallback<ToastContext["show"]>((message, options) => {
const id = nextId.current++;
setToasts((current) => [
...current.slice(-2),
{ id, message, tone: options?.tone ?? "info", undo: options?.undo },
]);
}, []);
const report = useCallback<ToastContext["report"]>(
(result, undo) => {
if (!result.ok) {
show(result.message ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
if (!result.message) return;
const ids = result.watchIds ?? [];
show(result.message, {
undo: undo && ids.length > 0 ? () => void undo(ids) : undefined,
});
},
[show],
);
return (
<Context.Provider value={{ show, report }}>
{children}
<div
aria-live="polite"
aria-atomic="false"
className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2 px-4"
>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={() => dismiss(toast.id)} />
))}
</div>
</Context.Provider>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
useEffect(() => {
const timer = setTimeout(onDismiss, VISIBLE_MS);
return () => clearTimeout(timer);
}, [onDismiss]);
return (
<div
role={toast.tone === "error" ? "alert" : "status"}
className={clsx(
"pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border px-4 py-2.5 text-sm shadow-[var(--fl-shadow)] backdrop-blur-xl",
"motion-safe:animate-[fl-toast-in_240ms_var(--ease-glow)]",
)}
style={{
backgroundColor: "var(--fl-surface-solid)",
borderColor:
toast.tone === "error"
? "color-mix(in oklab, var(--fl-danger) 50%, transparent)"
: "var(--fl-border)",
color: toast.tone === "error" ? "var(--fl-danger)" : "var(--fl-text)",
}}
>
<span className="flex-1">{toast.message}</span>
{toast.undo && (
<button
type="button"
onClick={() => {
void toast.undo?.();
onDismiss();
}}
className="shrink-0 rounded-lg px-2 py-1 text-sm font-semibold text-[var(--fl-accent)] underline-offset-2 hover:underline"
>
Rückgängig
</button>
)}
<button
type="button"
onClick={onDismiss}
aria-label="Meldung schließen"
className="shrink-0 text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
</svg>
</button>
</div>
);
}
export function useToasts(): ToastContext {
const context = useContext(Context);
if (!context) {
throw new Error("useToasts braucht einen ToastProvider im Elternbaum.");
}
return context;
}
+232
View File
@@ -0,0 +1,232 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import {
addTitleAction,
setStatusAction,
startRewatchAction,
trashEntryAction,
updateEntryAction,
} from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { STATUS_COLOR, STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
/** Aufnahmeknopf für Titel, die noch nicht in der Bibliothek liegen. */
export function AddToLibrary({ titleId, isSeries }: { titleId: string; isSeries: boolean }) {
const { report } = useToasts();
const router = useRouter();
const [pending, startTransition] = useTransition();
function add(status: StatusKey) {
startTransition(async () => {
report(await addTitleAction(titleId, status));
router.refresh();
});
}
return (
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={pending}
onClick={() => add("WATCHING")}
className="fl-btn fl-btn-primary"
>
{isSeries ? "Ich schaue das gerade" : "In die Bibliothek"}
</button>
<button
type="button"
disabled={pending}
onClick={() => add("WATCHLIST")}
className="fl-btn fl-btn-ghost"
>
Auf die Watchlist
</button>
</div>
);
}
/** Statusauswahl als Chip-Reihe — schneller zu treffen als ein Auswahlfeld. */
export function StatusPicker({ entryId, status }: { entryId: string; status: StatusKey }) {
const { report } = useToasts();
const router = useRouter();
const [pending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useState(status);
function choose(next: StatusKey) {
if (next === optimistic) return;
setOptimistic(next);
startTransition(async () => {
const result = await setStatusAction(entryId, next);
if (!result.ok) setOptimistic(status);
report(result);
router.refresh();
});
}
return (
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Status">
{STATUS_ORDER.map((key) => {
const active = optimistic === key;
return (
<button
key={key}
type="button"
disabled={pending}
onClick={() => choose(key)}
aria-pressed={active}
className={clsx(
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
active
? "text-[var(--fl-text)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={
active
? {
borderColor: `color-mix(in oklab, ${STATUS_COLOR[key]} 60%, transparent)`,
backgroundColor: `color-mix(in oklab, ${STATUS_COLOR[key]} 16%, transparent)`,
}
: undefined
}
>
{STATUS_LABEL[key]}
</button>
);
})}
</div>
);
}
/** Bewertung von 1 bis 10. */
export function RatingPicker({ entryId, rating }: { entryId: string; rating: number | null }) {
const { report } = useToasts();
const router = useRouter();
const [value, setValue] = useState(rating);
const [hover, setHover] = useState<number | null>(null);
const [, startTransition] = useTransition();
function choose(next: number | null) {
setValue(next);
startTransition(async () => {
report(await updateEntryAction(entryId, { rating: next }));
router.refresh();
});
}
const shown = hover ?? value ?? 0;
return (
<div className="flex items-center gap-2">
<div
className="flex gap-0.5"
role="group"
aria-label="Bewertung von 1 bis 10"
onMouseLeave={() => setHover(null)}
>
{Array.from({ length: 10 }, (_, index) => index + 1).map((step) => (
<button
key={step}
type="button"
aria-label={`${step} von 10`}
aria-pressed={value === step}
onMouseEnter={() => setHover(step)}
onFocus={() => setHover(step)}
onBlur={() => setHover(null)}
onClick={() => choose(value === step ? null : step)}
className="group p-0.5"
>
<span
className={clsx(
"block h-5 w-2.5 rounded-full transition-colors",
step <= shown ? "" : "bg-[var(--fl-surface-2)]",
)}
style={step <= shown ? { backgroundImage: "var(--fl-gradient)" } : undefined}
/>
</button>
))}
</div>
<span className="w-14 text-sm tabular-nums text-[var(--fl-muted)]">
{value ? `${value}/10` : "—"}
</span>
</div>
);
}
/** Papierkorb und neuer Durchlauf. */
export function EntryActions({
entryId,
currentRun,
canRewatch,
}: {
entryId: string;
currentRun: number;
canRewatch: boolean;
}) {
const { report } = useToasts();
const router = useRouter();
const [pending, startTransition] = useTransition();
const [confirming, setConfirming] = useState(false);
return (
<div className="flex flex-wrap items-center gap-2">
{canRewatch && (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await startRewatchAction(entryId));
router.refresh();
})
}
className="fl-btn fl-btn-ghost text-sm"
>
Noch einmal schauen
</button>
)}
{currentRun > 0 && (
<span className="text-xs text-[var(--fl-faint)]">Durchlauf {currentRun + 1}</span>
)}
<span className="flex-1" />
{confirming ? (
<span className="flex items-center gap-2 text-sm">
<span className="text-[var(--fl-muted)]">In den Papierkorb?</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await trashEntryAction(entryId));
router.push("/bibliothek");
})
}
className="fl-btn fl-btn-ghost px-2.5 py-1 text-sm text-[var(--fl-danger)]"
>
Ja, weg damit
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="fl-btn fl-btn-ghost px-2.5 py-1 text-sm"
>
Abbrechen
</button>
</span>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Aus der Bibliothek entfernen
</button>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { setTagsAction, updateEntryAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
/**
* Notizfeld, „wo schaue ich das" und eigene Tags.
*
* Gespeichert wird beim Verlassen des Feldes, nicht bei jedem Tastendruck —
* das spart Anfragen und erspart das Suchen nach einem Speichern-Knopf.
*/
export function EntryNotes({
entryId,
notes,
whereToWatch,
tags,
suggestion,
}: {
entryId: string;
notes: string | null;
whereToWatch: string | null;
tags: string[];
/** Vorschlag aus der Streaming-Verfügbarkeit. */
suggestion: string | null;
}) {
return (
<div className="space-y-4">
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
<TagEditor entryId={entryId} tags={tags} />
<Notes entryId={entryId} value={notes} />
</div>
);
}
function useAutosave(entryId: string) {
const { report } = useToasts();
const router = useRouter();
const [, startTransition] = useTransition();
return (data: Record<string, unknown>) =>
startTransition(async () => {
const result = await updateEntryAction(entryId, data);
if (!result.ok) report(result);
router.refresh();
});
}
function WhereToWatch({
entryId,
value,
suggestion,
}: {
entryId: string;
value: string | null;
suggestion: string | null;
}) {
const save = useAutosave(entryId);
const [text, setText] = useState(value ?? "");
const initial = useRef(value ?? "");
return (
<div className="space-y-1.5">
<label htmlFor="whereToWatch" className="block text-sm font-medium text-[var(--fl-muted)]">
Wo schaue ich das?
</label>
<input
id="whereToWatch"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ whereToWatch: text });
}}
placeholder={suggestion ? `Vorschlag: ${suggestion}` : "z. B. Netflix, Blu-ray, Mediathek"}
className="fl-input"
/>
{suggestion && text !== suggestion && (
<button
type="button"
onClick={() => {
setText(suggestion);
initial.current = suggestion;
save({ whereToWatch: suggestion });
}}
className="text-xs text-[var(--fl-accent)] underline-offset-2 hover:underline"
>
{suggestion} übernehmen
</button>
)}
</div>
);
}
function Notes({ entryId, value }: { entryId: string; value: string | null }) {
const save = useAutosave(entryId);
const [text, setText] = useState(value ?? "");
const initial = useRef(value ?? "");
return (
<div className="space-y-1.5">
<label htmlFor="notes" className="block text-sm font-medium text-[var(--fl-muted)]">
Notiz
</label>
<textarea
id="notes"
value={text}
rows={4}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ notes: text });
}}
placeholder="Was du dir merken willst — Lieblingsfolge, mit wem du das schaust, wo du aufgehört hast."
className="fl-input resize-y"
/>
</div>
);
}
function TagEditor({ entryId, tags }: { entryId: string; tags: string[] }) {
const { report } = useToasts();
const router = useRouter();
const [current, setCurrent] = useState(tags);
const [draft, setDraft] = useState("");
const [, startTransition] = useTransition();
function commit(next: string[]) {
setCurrent(next);
startTransition(async () => {
const result = await setTagsAction(entryId, next);
if (!result.ok) {
report(result);
setCurrent(tags);
}
router.refresh();
});
}
function add() {
const name = draft.trim().toLocaleLowerCase("de-DE");
setDraft("");
if (!name || current.includes(name)) return;
commit([...current, name]);
}
return (
<div className="space-y-1.5">
<label htmlFor="tag-input" className="block text-sm font-medium text-[var(--fl-muted)]">
Tags
</label>
{current.length > 0 && (
<ul className="flex flex-wrap gap-1.5">
{current.map((tag) => (
<li key={tag}>
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--fl-border)] py-1 pl-3 pr-1.5 text-xs">
{tag}
<button
type="button"
onClick={() => commit(current.filter((entry) => entry !== tag))}
aria-label={`Tag ${tag} entfernen`}
className="rounded-full p-0.5 text-[var(--fl-faint)] hover:text-[var(--fl-danger)]"
>
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
</svg>
</button>
</span>
</li>
))}
</ul>
)}
<input
id="tag-input"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
add();
}
}}
onBlur={add}
placeholder="Tag eingeben und Enter drücken"
className="fl-input"
/>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import Link from "next/link";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import { STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
import type { LibraryItem } from "@/lib/queries";
/**
* Posterkachel der Bibliothek.
*
* Alles Wesentliche steht ohne Klick da: Status, Fortschritt, Bewertung.
* Die Karte ist ein Link — der ganze Bereich ist anklickbar, nicht nur der Titel.
*/
export function LibraryCard({ item }: { item: LibraryItem }) {
const status = item.status as StatusKey;
const showProgress = item.type === "SERIES" && item.total > 0 && item.status !== "WATCHLIST";
return (
<Link href={`/titel/${item.titleId}`} className="fl-poster-card group block focus:outline-none">
<div className="relative">
<Poster path={item.posterPath} alt={item.title} size="w342" />
<span
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
style={{
color: STATUS_COLOR[status],
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
}}
>
{STATUS_LABEL[status]}
</span>
{item.rating != null && (
<span
className="absolute right-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
>
{item.rating}
</span>
)}
{item.currentRun > 0 && (
<span className="absolute bottom-2 right-2 rounded-full bg-[color-mix(in_oklab,var(--fl-bg)_78%,transparent)] px-2 py-0.5 text-[0.65rem] font-medium text-[var(--fl-muted)] backdrop-blur-md">
{item.currentRun + 1}. Durchlauf
</span>
)}
</div>
<div className="mt-2 space-y-1">
<p className="line-clamp-2 text-sm font-medium leading-snug">{item.title}</p>
<p className="text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""}
</p>
{showProgress && (
<div className="pt-0.5">
<ProgressBar
percent={item.percent}
label={`${item.watched} von ${item.total} Episoden gesehen`}
/>
<p className="mt-1 text-xs text-[var(--fl-faint)]">
{item.watched}/{item.total} Episoden
</p>
</div>
)}
</div>
</Link>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx";
import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
import type { LibraryFacets } from "@/lib/queries";
/**
* Filterleiste der Bibliothek.
*
* Der gesamte Zustand steht in der URL: Ansichten sind teilbar, der
* Zurück-Knopf tut das Erwartbare, und die Liste selbst bleibt eine
* Server-Komponente ohne eigenen Zustand.
*/
const SORTS = [
{ key: "aktualisiert", label: "Zuletzt aktualisiert" },
{ key: "hinzugefuegt", label: "Zuletzt hinzugefügt" },
{ key: "titel", label: "Titel" },
{ key: "bewertung", label: "Bewertung" },
{ key: "jahr", label: "Erscheinungsjahr" },
] as const;
export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const [, startTransition] = useTransition();
const [query, setQuery] = useState(params.get("q") ?? "");
const first = useRef(true);
function apply(changes: Record<string, string | null>) {
const next = new URLSearchParams(params.toString());
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === "") next.delete(key);
else next.set(key, value);
}
startTransition(() => {
router.replace(next.size ? `${pathname}?${next}` : pathname, { scroll: false });
});
}
// Volltextsuche gesammelt anwenden, nicht bei jedem Tastendruck.
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
const timer = setTimeout(() => apply({ q: query.trim() || null }), 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const status = params.get("status");
const type = params.get("typ");
const sort = params.get("sortierung") ?? "aktualisiert";
const activeCount = ["genre", "tag", "dienst", "typ"].filter((key) => params.get(key)).length;
return (
<div className="space-y-3">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="In der Bibliothek suchen …"
aria-label="Bibliothek durchsuchen"
className="fl-input"
/>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern">
<Chip active={!status} onClick={() => apply({ status: null })}>
Alle <Count value={facets.counts.ALLE} />
</Chip>
{STATUS_ORDER.map((key) => (
<Chip key={key} active={status === key} onClick={() => apply({ status: key })}>
{STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} />
</Chip>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
label="Typ"
value={type ?? ""}
onChange={(value) => apply({ typ: value || null })}
options={[
{ value: "", label: "Serien und Filme" },
{ value: "SERIES", label: "Nur Serien" },
{ value: "MOVIE", label: "Nur Filme" },
]}
/>
{facets.genres.length > 0 && (
<Select
label="Genre"
value={params.get("genre") ?? ""}
onChange={(value) => apply({ genre: value || null })}
options={[
{ value: "", label: "Alle Genres" },
...facets.genres.map((genre) => ({ value: genre, label: genre })),
]}
/>
)}
{facets.providers.length > 0 && (
<Select
label="Dienst"
value={params.get("dienst") ?? ""}
onChange={(value) => apply({ dienst: value || null })}
options={[
{ value: "", label: "Alle Dienste" },
...facets.providers.map((name) => ({ value: name, label: name })),
]}
/>
)}
{facets.tags.length > 0 && (
<Select
label="Tag"
value={params.get("tag") ?? ""}
onChange={(value) => apply({ tag: value || null })}
options={[
{ value: "", label: "Alle Tags" },
...facets.tags.map((tag) => ({ value: tag, label: tag })),
]}
/>
)}
<Select
label="Sortierung"
value={sort}
onChange={(value) => apply({ sortierung: value === "aktualisiert" ? null : value })}
options={SORTS.map((entry) => ({ value: entry.key, label: entry.label }))}
/>
{(activeCount > 0 || status || query) && (
<button
type="button"
onClick={() => {
setQuery("");
startTransition(() => router.replace(pathname, { scroll: false }));
}}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
Filter zurücksetzen
</button>
)}
</div>
</div>
);
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={clsx(
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
active
? "border-transparent text-[var(--fl-on-accent)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={active ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{children}
</button>
);
}
function Count({ value }: { value: number }) {
return <span className="ml-1 text-xs tabular-nums opacity-70">{value}</span>;
}
function Select({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
return (
<label className="inline-flex items-center gap-1.5 text-sm">
<span className="sr-only">{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
aria-label={label}
className="rounded-lg border border-[var(--fl-border)] bg-[var(--fl-surface-2)] px-2.5 py-1.5 text-sm text-[var(--fl-text)] transition-colors hover:border-[var(--fl-border-strong)]"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createManualTitleAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { STATUS_LABEL, STATUS_ORDER } from "@/lib/labels";
/**
* Titel von Hand anlegen — für alles, was TMDB nicht kennt.
*
* Pflicht ist nur der Titel. Alles Weitere ist optional, weil man bei einem
* Heimvideo oder einem Vortragsmitschnitt selten die Laufzeit zur Hand hat.
*/
export function ManualTitleForm() {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const [type, setType] = useState<"SERIES" | "MOVIE">("MOVIE");
const [error, setError] = useState<string | null>(null);
function submit(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
if (!title) {
setError("Ohne Titel geht es nicht.");
return;
}
setError(null);
const year = Number.parseInt(String(formData.get("year") ?? ""), 10);
const runtime = Number.parseInt(String(formData.get("runtime") ?? ""), 10);
const genres = String(formData.get("genres") ?? "")
.split(",")
.map((genre) => genre.trim())
.filter(Boolean);
startTransition(async () => {
const result = await createManualTitleAction({
type,
title,
year: Number.isFinite(year) ? year : null,
runtime: Number.isFinite(runtime) ? runtime : null,
overview: String(formData.get("overview") ?? ""),
genres,
status: String(formData.get("status") ?? "WATCHLIST"),
});
report(result);
if (result.ok && result.titleId) router.push(`/titel/${result.titleId}`);
});
}
return (
<form action={submit} className="space-y-4">
<fieldset>
<legend className="mb-2 block text-sm font-medium text-[var(--fl-muted)]">Was ist es?</legend>
<div className="flex gap-2">
{(["MOVIE", "SERIES"] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setType(option)}
aria-pressed={type === option}
className="rounded-full border px-4 py-2 text-sm font-medium transition-colors"
style={
type === option
? { backgroundImage: "var(--fl-gradient)", color: "var(--fl-on-accent)", borderColor: "transparent" }
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
}
>
{option === "MOVIE" ? "Film" : "Serie"}
</button>
))}
</div>
</fieldset>
<Field id="title" label="Titel" required autoFocus />
<div className="grid gap-4 sm:grid-cols-2">
<Field id="year" label="Erscheinungsjahr" type="number" placeholder="2026" />
<Field
id="runtime"
label={type === "SERIES" ? "Minuten je Episode" : "Laufzeit in Minuten"}
type="number"
placeholder="95"
/>
</div>
<Field id="genres" label="Genres" placeholder="Drama, Komödie — durch Komma getrennt" />
<div className="space-y-1.5">
<label htmlFor="overview" className="block text-sm font-medium text-[var(--fl-muted)]">
Beschreibung
</label>
<textarea id="overview" name="overview" rows={3} className="fl-input resize-y" />
</div>
<div className="space-y-1.5">
<label htmlFor="status" className="block text-sm font-medium text-[var(--fl-muted)]">
Status
</label>
<select id="status" name="status" defaultValue="WATCHLIST" className="fl-input">
{STATUS_ORDER.map((key) => (
<option key={key} value={key}>
{STATUS_LABEL[key]}
</option>
))}
</select>
</div>
{error && (
<p role="alert" className="text-sm text-[var(--fl-danger)]">
{error}
</p>
)}
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
{pending ? "Einen Moment …" : "Anlegen und aufnehmen"}
</button>
</form>
);
}
function Field({
id,
label,
type = "text",
placeholder,
required = false,
autoFocus = false,
}: {
id: string;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
autoFocus?: boolean;
}) {
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-[var(--fl-muted)]">
{label}
{!required && <span className="ml-1 text-xs text-[var(--fl-faint)]">(optional)</span>}
</label>
<input
id={id}
name={id}
type={type}
placeholder={placeholder}
required={required}
autoFocus={autoFocus}
className="fl-input"
/>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { markMovieAction, undoAction, watchNextAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
/**
* „Als Nächstes dran" mit dem Schnellknopf.
*
* Das ist der meistbenutzte Knopf der ganzen Anwendung — deshalb steht er
* oben, ist groß, und sagt vorher, was er abhaken wird.
*/
export function NextEpisodeCard({
entryId,
next,
}: {
entryId: string;
next: {
id: string;
name: string;
seasonNumber: number;
episodeNumber: number;
airDate: string | null;
runtime: number | null;
};
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const upcoming = isUpcoming(next.airDate);
const code = episodeCode(next.seasonNumber, next.episodeNumber);
async function undo(watchIds: string[]) {
await undoAction(entryId, watchIds);
router.refresh();
}
return (
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-2)] p-4">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Als Nächstes dran
</p>
<p className="mt-1 truncate font-medium">
<span className="fl-gradient-text font-mono font-semibold">{code}</span>{" "}
<span className="text-[var(--fl-text)]">{next.name}</span>
</p>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{upcoming
? `Erscheint erst am ${formatDate(next.airDate)}`
: [formatDate(next.airDate), next.runtime ? `${next.runtime} min` : null]
.filter(Boolean)
.join(" · ") || "Kein Ausstrahlungsdatum hinterlegt"}
</p>
</div>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await watchNextAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-primary"
title={`${code} als gesehen markieren`}
>
+1 Episode
</button>
</div>
);
}
/** Sichtungsknopf für Filme. */
export function WatchMovieButton({
entryId,
watched,
}: {
entryId: string;
watched: boolean;
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
async function undo(watchIds: string[]) {
await undoAction(entryId, watchIds);
router.refresh();
}
if (watched) {
return (
<p className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Gesehen
</p>
);
}
return (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await markMovieAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-primary"
>
Als gesehen markieren
</button>
);
}
+335
View File
@@ -0,0 +1,335 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import {
markUpToAction,
toggleEpisodeAction,
toggleSeasonAction,
undoAction,
} from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
/**
* Staffel- und Episodenliste mit Fortschritt.
*
* Liegt der Titel in der Bibliothek (`entryId` gesetzt), lässt sich jede
* Episode abhaken, eine ganze Staffel auf einmal, oder „bis hierhin" alles
* Vorherige. Ohne Eintrag ist die Liste eine reine Übersicht.
*
* Die Häkchen reagieren sofort (optimistisch) und werden zurückgedreht, wenn
* der Server widerspricht — Abhaken soll sich anfühlen wie ein Schalter, nicht
* wie ein Formular.
*/
export type EpisodeData = {
id: string;
episodeNumber: number;
name: string;
overview: string | null;
airDate: string | null;
runtime: number | null;
};
export type SeasonData = {
id: string;
seasonNumber: number;
name: string;
overview: string | null;
airDate: string | null;
episodes: EpisodeData[];
};
export function SeasonList({
seasons,
entryId,
watchedIds,
}: {
seasons: SeasonData[];
entryId?: string;
watchedIds?: string[];
}) {
const router = useRouter();
const { report } = useToasts();
const [watched, setWatched] = useState(() => new Set(watchedIds ?? []));
const [, startTransition] = useTransition();
// Nach einem Serverabgleich (router.refresh) den Stand übernehmen.
useEffect(() => {
setWatched(new Set(watchedIds ?? []));
}, [watchedIds]);
const firstOpen = seasons.find((season) => season.seasonNumber > 0) ?? seasons[0];
const [open, setOpen] = useState<string[]>(firstOpen ? [firstOpen.id] : []);
async function undo(watchIds: string[]) {
if (!entryId) return;
await undoAction(entryId, watchIds);
router.refresh();
}
function toggleEpisode(episodeId: string, next: boolean) {
if (!entryId) return;
setWatched((current) => {
const copy = new Set(current);
if (next) copy.add(episodeId);
else copy.delete(episodeId);
return copy;
});
startTransition(async () => {
const result = await toggleEpisodeAction(entryId, episodeId, next);
if (!result.ok) {
setWatched((current) => {
const copy = new Set(current);
if (next) copy.delete(episodeId);
else copy.add(episodeId);
return copy;
});
}
report(result, next ? (ids) => undo(ids) : undefined);
router.refresh();
});
}
function toggleSeason(season: SeasonData, next: boolean) {
if (!entryId) return;
const ids = season.episodes.map((episode) => episode.id);
setWatched((current) => {
const copy = new Set(current);
for (const id of ids) {
if (next) copy.add(id);
else copy.delete(id);
}
return copy;
});
startTransition(async () => {
const result = await toggleSeasonAction(entryId, season.id, next);
report(result, next ? (watchIds) => undo(watchIds) : undefined);
router.refresh();
});
}
function markUpTo(episodeId: string) {
if (!entryId) return;
startTransition(async () => {
const result = await markUpToAction(entryId, episodeId);
report(result, (watchIds) => undo(watchIds));
router.refresh();
});
}
if (seasons.length === 0) {
return (
<p className="text-sm text-[var(--fl-faint)]">Zu dieser Serie liegen keine Staffeln vor.</p>
);
}
return (
<div className="space-y-2">
{seasons.map((season) => {
const isOpen = open.includes(season.id);
const seen = season.episodes.filter((episode) => watched.has(episode.id)).length;
const complete = season.episodes.length > 0 && seen === season.episodes.length;
return (
<section key={season.id} className="fl-card overflow-hidden">
<div className="flex items-center gap-2 px-2 py-1.5 sm:px-3">
<button
type="button"
onClick={() =>
setOpen((current) =>
current.includes(season.id)
? current.filter((id) => id !== season.id)
: [...current, season.id],
)
}
aria-expanded={isOpen}
className="flex flex-1 items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-[var(--fl-surface-2)]"
>
<Chevron open={isOpen} />
<span className="min-w-0 flex-1">
<span className="font-medium">{season.name}</span>
<span className="ml-2 text-sm text-[var(--fl-faint)]">
{entryId
? `${seen} von ${season.episodes.length} gesehen`
: `${season.episodes.length} ${season.episodes.length === 1 ? "Episode" : "Episoden"}`}
{season.airDate ? ` · ab ${formatDate(season.airDate)}` : ""}
</span>
</span>
</button>
{entryId && season.episodes.length > 0 && (
<button
type="button"
onClick={() => toggleSeason(season, !complete)}
className="shrink-0 rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
>
{complete ? "Staffel zurücksetzen" : "Staffel abhaken"}
</button>
)}
</div>
{isOpen && (
<ol className="border-t border-[var(--fl-border)]">
{season.episodes.map((episode) => (
<EpisodeRow
key={episode.id}
episode={episode}
seasonNumber={season.seasonNumber}
watched={watched.has(episode.id)}
interactive={Boolean(entryId)}
onToggle={(next) => toggleEpisode(episode.id, next)}
onMarkUpTo={() => markUpTo(episode.id)}
/>
))}
{season.episodes.length === 0 && (
<li className="px-4 py-3 text-sm text-[var(--fl-faint)]">
Für diese Staffel liegen noch keine Episoden vor.
</li>
)}
</ol>
)}
</section>
);
})}
</div>
);
}
function EpisodeRow({
episode,
seasonNumber,
watched,
interactive,
onToggle,
onMarkUpTo,
}: {
episode: EpisodeData;
seasonNumber: number;
watched: boolean;
interactive: boolean;
onToggle: (next: boolean) => void;
onMarkUpTo: () => void;
}) {
const [glimmer, setGlimmer] = useState(false);
const previous = useRef(watched);
// Beim Abhaken glimmt die Zeile einmal warm auf.
useEffect(() => {
if (watched && !previous.current) {
setGlimmer(true);
const timer = setTimeout(() => setGlimmer(false), 900);
previous.current = watched;
return () => clearTimeout(timer);
}
previous.current = watched;
}, [watched]);
const upcoming = isUpcoming(episode.airDate);
return (
<li
className={clsx(
"group flex items-start gap-3 border-b border-[var(--fl-border)] px-3 py-2.5 last:border-b-0 sm:px-4",
glimmer && "fl-glimmer",
)}
>
{interactive ? (
<label className="flex cursor-pointer items-center gap-3 pt-0.5">
<input
type="checkbox"
checked={watched}
onChange={(event) => onToggle(event.target.checked)}
className="sr-only"
/>
<Checkbox checked={watched} />
<span className="sr-only">
{episodeCode(seasonNumber, episode.episodeNumber)} als gesehen markieren
</span>
</label>
) : (
<span className="w-5" />
)}
<span className="w-14 shrink-0 pt-1 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(seasonNumber, episode.episodeNumber)}
</span>
<span className="min-w-0 flex-1">
<span
className={clsx(
"block text-sm font-medium",
watched && "text-[var(--fl-muted)]",
)}
>
{episode.name}
{upcoming && (
<span className="ml-2 rounded-full border border-[var(--fl-border)] px-2 py-0.5 align-middle text-[0.65rem] font-normal text-[var(--fl-faint)]">
noch nicht ausgestrahlt
</span>
)}
</span>
{episode.overview && (
<span className="mt-0.5 line-clamp-2 block text-xs text-[var(--fl-faint)]">
{episode.overview}
</span>
)}
{interactive && !watched && (
<button
type="button"
onClick={onMarkUpTo}
className="mt-1 text-xs text-[var(--fl-accent)] opacity-0 transition-opacity underline-offset-2 hover:underline focus:opacity-100 group-hover:opacity-100"
>
Bis hierhin gesehen
</button>
)}
</span>
<span className="shrink-0 pt-1 text-right text-xs text-[var(--fl-faint)]">
{formatDate(episode.airDate) ?? "—"}
{episode.runtime ? <span className="block">{episode.runtime} min</span> : null}
</span>
</li>
);
}
function Checkbox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden
className={clsx(
"grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-all duration-200",
checked ? "border-transparent" : "border-[var(--fl-border-strong)]",
)}
style={checked ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{checked && (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="var(--fl-on-accent)" strokeWidth="3.5">
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 24 24"
className={clsx(
"h-4 w-4 shrink-0 text-[var(--fl-muted)] transition-transform duration-200",
open && "rotate-90",
)}
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<path d="m9 6 6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { purgeEntryAction, restoreEntryAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
/**
* Wiederherstellen oder endgültig löschen.
* Das endgültige Löschen fragt nach — es lässt sich nicht zurücknehmen.
*/
export function TrashActions({ entryId, title }: { entryId: string; title: string }) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const [confirming, setConfirming] = useState(false);
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await restoreEntryAction(entryId));
router.refresh();
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
>
Wiederherstellen
</button>
{confirming ? (
<>
<span className="text-sm text-[var(--fl-muted)]">Endgültig löschen?</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await purgeEntryAction(entryId));
router.refresh();
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm text-[var(--fl-danger)]"
>
Ja, {title} löschen
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Endgültig löschen
</button>
)}
</div>
);
}
+366
View File
@@ -0,0 +1,366 @@
import "server-only";
import { prisma } from "./db";
import { env } from "./env";
import {
clearTmdbCache,
getCollection,
getMovie,
getSeason,
getSeries,
parseDate,
type TmdbProvider,
type TmdbWatchProviders,
} from "./tmdb";
import type { ProviderKind, Title, TitleType } from "@/generated/prisma/client";
/**
* Übernahme von TMDB-Daten in den lokalen Katalog.
*
* Der Katalog ist bewusst **gemeinsam** für alle Nutzer und über
* (Typ, TMDB-ID) dedupliziert: zehn Leute mit derselben Serie brauchen die
* Episodenliste nur einmal. Persönliches steckt ausschließlich in
* LibraryEntry und Watch.
*/
/** Staffel 0 sind Specials — sie zählen nicht zum regulären Fortschritt. */
export const SPECIALS_SEASON = 0;
/** Verwirft den TMDB-Antwortspeicher (Tests, Verwaltung „jetzt neu laden"). */
export function clearCatalogCaches() {
clearTmdbCache();
}
// --- Streamingdienste -------------------------------------------------------
const PROVIDER_KIND_MAP = {
flatrate: "FLATRATE",
rent: "RENT",
buy: "BUY",
ads: "ADS",
free: "FREE",
} as const satisfies Record<string, ProviderKind>;
/**
* Wiedererkennbare Akzentfarben der großen Dienste. Alles Unbekannte bekommt
* im UI einen neutralen Ton — deshalb ist die Liste nur eine Abkürzung, keine
* Voraussetzung.
*/
const PROVIDER_COLORS: Record<string, string> = {
netflix: "#e50914",
"amazon prime video": "#00a8e1",
"prime video": "#00a8e1",
"disney plus": "#0e47a1",
"disney+": "#0e47a1",
"apple tv+": "#8f8f8f",
"apple tv plus": "#8f8f8f",
"apple tv": "#8f8f8f",
wow: "#00a4e4",
"sky go": "#00a4e4",
paramount: "#0064ff",
"paramount plus": "#0064ff",
"rtl+": "#e30613",
joyn: "#f5008c",
"joyn plus": "#f5008c",
crunchyroll: "#f47521",
mubi: "#000000",
ard: "#0a3b78",
"ard mediathek": "#0a3b78",
zdf: "#fa7d19",
"zdf mediathek": "#fa7d19",
arte: "#ff6600",
magentatv: "#e20074",
"wow presents plus": "#00a4e4",
"google play movies": "#34a853",
"youtube premium": "#ff0000",
youtube: "#ff0000",
"maxdome store": "#ff6d00",
};
async function upsertProvider(provider: TmdbProvider) {
const color = PROVIDER_COLORS[provider.provider_name.trim().toLowerCase()] ?? null;
return prisma.watchProvider.upsert({
where: { tmdbId: provider.provider_id },
create: {
tmdbId: provider.provider_id,
name: provider.provider_name,
logoPath: provider.logo_path ?? null,
color,
},
update: {
name: provider.provider_name,
logoPath: provider.logo_path ?? null,
...(color ? { color } : {}),
},
select: { id: true },
});
}
/**
* Schreibt die Verfügbarkeit eines Titels für eine Region fort.
*
* Einträge, die es nicht mehr gibt, werden gelöscht; vorhandene bleiben
* unangetastet, damit `firstSeenAt` erhalten bleibt — daran hängt die
* Benachrichtigung „ist jetzt verfügbar".
*/
async function syncProviders(titleId: string, data: TmdbWatchProviders | undefined, region: string) {
const regional = data?.results?.[region];
const seen: { providerId: string; kind: ProviderKind }[] = [];
if (regional) {
for (const [key, kind] of Object.entries(PROVIDER_KIND_MAP)) {
const list = regional[key as keyof typeof regional];
if (!Array.isArray(list)) continue;
for (const provider of list as TmdbProvider[]) {
const row = await upsertProvider(provider);
seen.push({ providerId: row.id, kind });
}
}
}
await prisma.$transaction([
prisma.titleProvider.deleteMany({
where: {
titleId,
region,
NOT: seen.map(({ providerId, kind }) => ({ providerId, kind })),
},
}),
...seen.map(({ providerId, kind }) =>
prisma.titleProvider.upsert({
where: { titleId_providerId_region_kind: { titleId, providerId, region, kind } },
create: { titleId, providerId, region, kind },
update: {},
}),
),
]);
}
// --- Filme ------------------------------------------------------------------
export async function importMovie(tmdbId: number, region = env.tmdb.region): Promise<Title> {
const data = await getMovie(tmdbId);
const title = await prisma.title.upsert({
where: { type_tmdbId: { type: "MOVIE", tmdbId } },
create: {
type: "MOVIE",
tmdbId,
imdbId: data.imdb_id ?? null,
title: data.title,
originalTitle: data.original_title ?? null,
overview: data.overview ?? null,
tagline: data.tagline ?? null,
posterPath: data.poster_path ?? null,
backdropPath: data.backdrop_path ?? null,
releaseDate: parseDate(data.release_date),
runtime: data.runtime ?? null,
status: data.status ?? null,
genres: data.genres?.map((g) => g.name) ?? [],
originalLang: data.original_language ?? null,
homepage: data.homepage ?? null,
voteAverage: data.vote_average ?? null,
tmdbCollectionId: data.belongs_to_collection?.id ?? null,
tmdbCollectionName: data.belongs_to_collection?.name ?? null,
syncedAt: new Date(),
},
update: {
imdbId: data.imdb_id ?? null,
title: data.title,
originalTitle: data.original_title ?? null,
overview: data.overview ?? null,
tagline: data.tagline ?? null,
posterPath: data.poster_path ?? null,
backdropPath: data.backdrop_path ?? null,
releaseDate: parseDate(data.release_date),
runtime: data.runtime ?? null,
status: data.status ?? null,
genres: data.genres?.map((g) => g.name) ?? [],
originalLang: data.original_language ?? null,
homepage: data.homepage ?? null,
voteAverage: data.vote_average ?? null,
tmdbCollectionId: data.belongs_to_collection?.id ?? null,
tmdbCollectionName: data.belongs_to_collection?.name ?? null,
syncedAt: new Date(),
},
});
await syncProviders(title.id, data["watch/providers"], region);
return title;
}
// --- Serien -----------------------------------------------------------------
export type ImportSeriesOptions = {
region?: string;
/** Nur Kopfdaten holen — spart bei der Suche die Episodenabrufe. */
skipEpisodes?: boolean;
};
export async function importSeries(
tmdbId: number,
{ region = env.tmdb.region, skipEpisodes = false }: ImportSeriesOptions = {},
): Promise<Title> {
const data = await getSeries(tmdbId);
const averageRuntime = data.episode_run_time?.[0] ?? null;
const common = {
imdbId: data.external_ids?.imdb_id ?? null,
title: data.name,
originalTitle: data.original_name ?? null,
overview: data.overview ?? null,
tagline: data.tagline ?? null,
posterPath: data.poster_path ?? null,
backdropPath: data.backdrop_path ?? null,
releaseDate: parseDate(data.first_air_date),
lastAirDate: parseDate(data.last_air_date),
runtime: averageRuntime,
status: data.status ?? null,
inProduction: data.in_production ?? false,
genres: data.genres?.map((g) => g.name) ?? [],
originalLang: data.original_language ?? null,
homepage: data.homepage ?? null,
voteAverage: data.vote_average ?? null,
syncedAt: new Date(),
};
const title = await prisma.title.upsert({
where: { type_tmdbId: { type: "SERIES", tmdbId } },
create: { type: "SERIES", tmdbId, ...common },
update: common,
});
await syncProviders(title.id, data["watch/providers"], region);
if (skipEpisodes) return title;
await importSeasons(title.id, tmdbId, data.seasons ?? [], averageRuntime);
// Erneut lesen: importSeasons schreibt Staffel- und Episodenzähler fort,
// das oben zurückgegebene Objekt wäre an dieser Stelle veraltet.
return prisma.title.findUniqueOrThrow({ where: { id: title.id } });
}
async function importSeasons(
titleId: string,
tmdbId: number,
seasons: NonNullable<Awaited<ReturnType<typeof getSeries>>["seasons"]>,
fallbackRuntime: number | null,
) {
for (const summary of seasons) {
const season = await prisma.season.upsert({
where: { titleId_seasonNumber: { titleId, seasonNumber: summary.season_number } },
create: {
titleId,
tmdbId: summary.id,
seasonNumber: summary.season_number,
name: summary.name,
overview: summary.overview ?? null,
posterPath: summary.poster_path ?? null,
airDate: parseDate(summary.air_date),
episodeCount: summary.episode_count ?? 0,
},
update: {
tmdbId: summary.id,
name: summary.name,
overview: summary.overview ?? null,
posterPath: summary.poster_path ?? null,
airDate: parseDate(summary.air_date),
episodeCount: summary.episode_count ?? 0,
},
select: { id: true },
});
const details = await getSeason(tmdbId, summary.season_number);
for (const episode of details.episodes ?? []) {
const payload = {
tmdbId: episode.id,
seasonId: season.id,
name: episode.name,
overview: episode.overview ?? null,
stillPath: episode.still_path ?? null,
airDate: parseDate(episode.air_date),
runtime: episode.runtime ?? fallbackRuntime,
};
await prisma.episode.upsert({
where: {
titleId_seasonNumber_episodeNumber: {
titleId,
seasonNumber: episode.season_number,
episodeNumber: episode.episode_number,
},
},
create: {
titleId,
seasonNumber: episode.season_number,
episodeNumber: episode.episode_number,
...payload,
},
update: payload,
});
}
}
await refreshSeriesCounts(titleId);
}
/**
* Zählt Staffeln und Episoden neu — Specials (Staffel 0) bleiben außen vor,
* damit der Fortschritt nicht an Weihnachtsfolgen hängen bleibt.
*/
export async function refreshSeriesCounts(titleId: string) {
const [seasonCount, episodeCount] = await Promise.all([
prisma.season.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
prisma.episode.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
]);
await prisma.title.update({
where: { id: titleId },
data: { seasonCount, episodeCount },
});
}
// --- gemeinsame Einstiege ---------------------------------------------------
export function importTitle(type: TitleType, tmdbId: number, region = env.tmdb.region) {
return type === "SERIES" ? importSeries(tmdbId, { region }) : importMovie(tmdbId, region);
}
/**
* Liefert den Titel aus dem lokalen Katalog und holt ihn bei Bedarf von TMDB.
* `maxAgeHours` bestimmt, ab wann ein vorhandener Eintrag aufgefrischt wird.
*/
export async function ensureTitle(
type: TitleType,
tmdbId: number,
{ maxAgeHours = 24 * 7, region = env.tmdb.region } = {},
): Promise<Title> {
const existing = await prisma.title.findUnique({ where: { type_tmdbId: { type, tmdbId } } });
if (existing?.syncedAt) {
const ageHours = (Date.now() - existing.syncedAt.getTime()) / 3_600_000;
if (ageHours < maxAgeHours) return existing;
}
try {
return await importTitle(type, tmdbId, region);
} catch (error) {
// Steht der Titel bereits im Katalog, ist eine gescheiterte Auffrischung
// kein Grund, die Seite platzen zu lassen.
if (existing) return existing;
throw error;
}
}
/** Übernimmt eine TMDB-Collection als Liste von Filmen (Grundlage für Universen). */
export async function importCollection(collectionTmdbId: number, region = env.tmdb.region) {
const collection = await getCollection(collectionTmdbId);
const titles: Title[] = [];
for (const part of collection.parts ?? []) {
titles.push(await importMovie(part.id, region));
}
// Nach Erscheinungsdatum, damit die Release-Reihenfolge direkt stimmt.
titles.sort((a, b) => (a.releaseDate?.getTime() ?? 0) - (b.releaseDate?.getTime() ?? 0));
return { collection, titles };
}
+107
View File
@@ -0,0 +1,107 @@
import "server-only";
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { env } from "./env";
import { IMAGE_BASE } from "./tmdb";
/**
* Lokaler Bild-Zwischenspeicher.
*
* Poster und Hintergrundbilder werden einmal von TMDB geholt und danach vom
* eigenen Server ausgeliefert. Das spart Anfragen, macht die Oberfläche auch
* ohne Internetverbindung nutzbar und verhindert, dass jeder Seitenaufruf
* einen Abruf bei einem Dritten auslöst.
*/
/** Zugelassene Größen — verhindert, dass jemand über den Parameter beliebige URLs baut. */
export const IMAGE_SIZES = [
"w92",
"w154",
"w185",
"w300",
"w342",
"w500",
"w780",
"w1280",
"original",
] as const;
export type ImageSize = (typeof IMAGE_SIZES)[number];
const PATH_PATTERN = /^\/[A-Za-z0-9]{8,64}\.(jpg|jpeg|png|svg|webp)$/;
export function isValidImagePath(path: string): boolean {
return PATH_PATTERN.test(path);
}
export function isValidSize(size: string): size is ImageSize {
return (IMAGE_SIZES as readonly string[]).includes(size);
}
function cacheDir() {
return join(env.dataDir, "images");
}
function cacheFile(size: string, path: string) {
// Der Dateiname wird gehasht, damit keine Pfadanteile aus der Anfrage in
// den Dateinamen wandern können.
const key = createHash("sha256").update(`${size}${path}`).digest("hex");
const extension = path.slice(path.lastIndexOf("."));
return join(cacheDir(), `${key}${extension}`);
}
export type CachedImage = {
data: Buffer;
contentType: string;
fromCache: boolean;
};
const CONTENT_TYPES: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
export async function getCachedImage(size: ImageSize, path: string): Promise<CachedImage> {
const file = cacheFile(size, path);
const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
const contentType = CONTENT_TYPES[extension] ?? "application/octet-stream";
try {
const info = await stat(file);
if (info.size > 0) {
return { data: await readFile(file), contentType, fromCache: true };
}
} catch {
// Noch nicht im Zwischenspeicher — dann eben holen.
}
const response = await fetch(`${IMAGE_BASE}/${size}${path}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Bild konnte nicht geladen werden (${response.status}).`);
}
const data = Buffer.from(await response.arrayBuffer());
// Erst in eine temporäre Datei schreiben und dann umbenennen: so sieht ein
// paralleler Abruf nie eine halb geschriebene Datei.
await mkdir(cacheDir(), { recursive: true });
const temporary = `${file}.${process.pid}.tmp`;
await writeFile(temporary, data);
await rename(temporary, file);
return {
data,
contentType: response.headers.get("content-type") ?? contentType,
fromCache: false,
};
}
/** URL des eigenen Bild-Endpunkts — überall im UI statt der TMDB-Adresse benutzen. */
export function posterUrl(path: string | null | undefined, size: ImageSize = "w342"): string | null {
if (!path || !isValidImagePath(path)) return null;
return `/api/image?size=${size}&path=${encodeURIComponent(path)}`;
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Beschriftungen und Formatierungen, die im Server- wie im Browsercode
* gebraucht werden. Bewusst frei von Server-Abhängigkeiten.
*/
export const STATUS_LABEL = {
WATCHING: "Schaue gerade",
PAUSED: "Pausiert",
COMPLETED: "Abgeschlossen",
DROPPED: "Abgebrochen",
WATCHLIST: "Watchlist",
} as const;
export type StatusKey = keyof typeof STATUS_LABEL;
export const STATUS_ORDER: StatusKey[] = [
"WATCHING",
"WATCHLIST",
"PAUSED",
"COMPLETED",
"DROPPED",
];
/** Farbtoken je Status — als CSS-Variable, damit Dark und Light beide stimmen. */
export const STATUS_COLOR: Record<StatusKey, string> = {
WATCHING: "var(--fl-accent-alt)",
WATCHLIST: "var(--fl-info)",
PAUSED: "var(--fl-warning)",
COMPLETED: "var(--fl-success)",
DROPPED: "var(--fl-faint)",
};
export const TYPE_LABEL = { SERIES: "Serie", MOVIE: "Film" } as const;
/** „S03E05" */
export function episodeCode(seasonNumber: number, episodeNumber: number): string {
return `S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}`;
}
/**
* Minuten menschenlesbar: „48 min", „3 h 20 min", „5 Tage 4 h".
* Ab einem Tag wird gerundet — auf die Minute genau interessiert dann niemanden.
*/
export function formatMinutes(minutes: number): string {
if (!Number.isFinite(minutes) || minutes <= 0) return "0 min";
const days = Math.floor(minutes / (60 * 24));
const hours = Math.floor((minutes % (60 * 24)) / 60);
const rest = Math.round(minutes % 60);
if (days > 0) {
return hours > 0
? `${days} ${days === 1 ? "Tag" : "Tage"} ${hours} h`
: `${days} ${days === 1 ? "Tag" : "Tage"}`;
}
if (hours > 0) return rest > 0 ? `${hours} h ${rest} min` : `${hours} h`;
return `${rest} min`;
}
export function formatDate(value: Date | string | null | undefined): string | null {
if (!value) return null;
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
}
export function formatDateLong(value: Date | string | null | undefined): string | null {
if (!value) return null;
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
}
/** „vor 3 Tagen", „heute", „in 2 Wochen" */
export function formatRelative(value: Date | string | null | undefined): string | null {
if (!value) return null;
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return null;
const diffDays = Math.round((date.getTime() - Date.now()) / 86_400_000);
if (diffDays === 0) return "heute";
if (diffDays === 1) return "morgen";
if (diffDays === -1) return "gestern";
const formatter = new Intl.RelativeTimeFormat("de-DE", { numeric: "auto" });
if (Math.abs(diffDays) < 30) return formatter.format(diffDays, "day");
if (Math.abs(diffDays) < 365) return formatter.format(Math.round(diffDays / 30), "month");
return formatter.format(Math.round(diffDays / 365), "year");
}
/** Ist das Datum in der Zukunft? (Für „noch nicht ausgestrahlt".) */
export function isUpcoming(value: Date | string | null | undefined): boolean {
if (!value) return false;
const date = typeof value === "string" ? new Date(value) : value;
return !Number.isNaN(date.getTime()) && date.getTime() > Date.now();
}
+509
View File
@@ -0,0 +1,509 @@
import "server-only";
import { prisma } from "./db";
import { SPECIALS_SEASON } from "./catalog";
import type { EntryStatus, LibraryEntry, Prisma, TitleType } from "@/generated/prisma/client";
/**
* Persönliches Tracking: Bibliothek, Status, Sichtungen, Fortschritt.
*
* Grundgedanke: Eine **Sichtung** (`Watch`) ist die einzige Wahrheit darüber,
* was jemand gesehen hat. Fortschritt, Statistik und „nächste Episode" werden
* daraus berechnet, nichts davon wird doppelt gespeichert. Rewatches zählen
* über `run` — Durchlauf 0 ist die Erstsichtung.
*/
export class LibraryError extends Error {}
// ---------------------------------------------------------------------------
// Aufnehmen und entfernen
// ---------------------------------------------------------------------------
/** Nimmt einen Titel in die Bibliothek auf oder gibt den vorhandenen Eintrag zurück. */
export async function addToLibrary(
userId: string,
titleId: string,
status: EntryStatus = "WATCHLIST",
): Promise<LibraryEntry> {
const existing = await prisma.libraryEntry.findUnique({
where: { userId_titleId: { userId, titleId } },
});
// Ein Titel im Papierkorb wird beim erneuten Aufnehmen wiederbelebt,
// statt einen zweiten Eintrag anzulegen — der Fortschritt bleibt erhalten.
if (existing) {
if (!existing.deletedAt) return existing;
return prisma.libraryEntry.update({
where: { id: existing.id },
data: { deletedAt: null, status },
});
}
const suggestion = await suggestWhereToWatch(userId, titleId);
return prisma.libraryEntry.create({
data: {
userId,
titleId,
status,
whereToWatch: suggestion,
startedAt: status === "WATCHING" ? new Date() : null,
},
});
}
/**
* Vorschlag für „wo schaue ich das": der erste Abo-Dienst in der Region des
* Nutzers. Rein als Startwert — überschreiben lässt sich das Feld jederzeit.
*/
async function suggestWhereToWatch(userId: string, titleId: string): Promise<string | null> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { region: true } });
if (!user) return null;
const provider = await prisma.titleProvider.findFirst({
where: { titleId, region: user.region, kind: { in: ["FLATRATE", "FREE", "ADS"] } },
include: { provider: { select: { name: true } } },
orderBy: { kind: "asc" },
});
return provider?.provider.name ?? null;
}
/** In den Papierkorb legen — 30 Tage lang wiederherstellbar. */
export async function moveToTrash(userId: string, entryId: string) {
return prisma.libraryEntry.update({
where: { id: entryId, userId },
data: { deletedAt: new Date() },
});
}
export async function restoreFromTrash(userId: string, entryId: string) {
return prisma.libraryEntry.update({
where: { id: entryId, userId },
data: { deletedAt: null },
});
}
/** Endgültig löschen. Die Sichtungen gehen dabei mit verloren. */
export async function purgeEntry(userId: string, entryId: string) {
await prisma.watch.deleteMany({ where: { userId, entryId } });
await prisma.libraryEntry.delete({ where: { id: entryId, userId } });
}
// ---------------------------------------------------------------------------
// Status und Angaben
// ---------------------------------------------------------------------------
export async function setStatus(userId: string, entryId: string, status: EntryStatus) {
const entry = await requireEntry(userId, entryId);
const data: Prisma.LibraryEntryUpdateInput = { status };
if (status === "WATCHING" && !entry.startedAt) data.startedAt = new Date();
if (status === "COMPLETED") data.finishedAt = new Date();
if (status === "WATCHLIST") {
data.startedAt = null;
data.finishedAt = null;
}
return prisma.libraryEntry.update({ where: { id: entryId }, data });
}
export async function updateEntryDetails(
userId: string,
entryId: string,
data: { rating?: number | null; notes?: string | null; whereToWatch?: string | null; favorite?: boolean },
) {
await requireEntry(userId, entryId);
if (data.rating != null && (data.rating < 1 || data.rating > 10)) {
throw new LibraryError("Die Bewertung muss zwischen 1 und 10 liegen.");
}
return prisma.libraryEntry.update({
where: { id: entryId },
data: {
...(data.rating !== undefined ? { rating: data.rating } : {}),
...(data.notes !== undefined ? { notes: data.notes?.trim() || null } : {}),
...(data.whereToWatch !== undefined
? { whereToWatch: data.whereToWatch?.trim() || null }
: {}),
...(data.favorite !== undefined ? { favorite: data.favorite } : {}),
},
});
}
async function requireEntry(userId: string, entryId: string) {
const entry = await prisma.libraryEntry.findFirst({
where: { id: entryId, userId },
include: { title: { select: { id: true, type: true, runtime: true, episodeCount: true } } },
});
if (!entry) throw new LibraryError("Dieser Eintrag gehört nicht zu deiner Bibliothek.");
return entry;
}
// ---------------------------------------------------------------------------
// Sichtungen
// ---------------------------------------------------------------------------
/**
* Ergebnis einer Sichtungsaktion.
*
* `watchIds` trägt die neu angelegten Sichtungen — damit lässt sich die
* Aktion mit einem Klick zurücknehmen, ohne raten zu müssen, was dazugehörte.
*/
export type WatchResult = {
watchIds: string[];
status: EntryStatus;
label: string;
};
/** Markiert einzelne Episoden als gesehen (im aktuellen Durchlauf). */
export async function markEpisodesWatched(
userId: string,
entryId: string,
episodeIds: string[],
watchedAt = new Date(),
): Promise<WatchResult> {
const entry = await requireEntry(userId, entryId);
if (entry.title.type !== "SERIES") {
throw new LibraryError("Episoden gibt es nur bei Serien.");
}
const episodes = await prisma.episode.findMany({
where: { id: { in: episodeIds }, titleId: entry.titleId },
select: { id: true, runtime: true, seasonNumber: true, episodeNumber: true },
});
const alreadySeen = new Set(
(
await prisma.watch.findMany({
where: { userId, episodeId: { in: episodes.map((e) => e.id) }, run: entry.currentRun },
select: { episodeId: true },
})
).map((watch) => watch.episodeId),
);
const toCreate = episodes.filter((episode) => !alreadySeen.has(episode.id));
const created = await prisma.$transaction(
toCreate.map((episode) =>
prisma.watch.create({
data: {
userId,
entryId,
titleId: entry.titleId,
episodeId: episode.id,
run: entry.currentRun,
watchedAt,
// Laufzeit wird eingefroren: spätere TMDB-Korrekturen sollen die
// bereits gezählte Schauzeit nicht rückwirkend verbiegen.
minutes: episode.runtime ?? entry.title.runtime ?? 0,
},
select: { id: true },
}),
),
);
const status = await reconcileStatus(userId, entryId);
return {
watchIds: created.map((watch) => watch.id),
status,
label:
toCreate.length === 1
? "Episode als gesehen markiert"
: `${toCreate.length} Episoden als gesehen markiert`,
};
}
/** Nimmt die Markierung einzelner Episoden im aktuellen Durchlauf zurück. */
export async function unmarkEpisodes(userId: string, entryId: string, episodeIds: string[]) {
const entry = await requireEntry(userId, entryId);
await prisma.watch.deleteMany({
where: { userId, entryId, episodeId: { in: episodeIds }, run: entry.currentRun },
});
return reconcileStatus(userId, entryId);
}
/** Ganze Staffel abhaken. */
export async function markSeasonWatched(userId: string, entryId: string, seasonId: string) {
const entry = await requireEntry(userId, entryId);
const episodes = await prisma.episode.findMany({
where: { seasonId, titleId: entry.titleId },
select: { id: true },
});
return markEpisodesWatched(
userId,
entryId,
episodes.map((episode) => episode.id),
);
}
export async function unmarkSeason(userId: string, entryId: string, seasonId: string) {
const entry = await requireEntry(userId, entryId);
const episodes = await prisma.episode.findMany({
where: { seasonId, titleId: entry.titleId },
select: { id: true },
});
return unmarkEpisodes(
userId,
entryId,
episodes.map((episode) => episode.id),
);
}
/**
* „Bis hierhin gesehen": markiert die Episode und alles davor.
* Praktisch, wenn jemand mitten in einer Staffel einsteigt.
*/
export async function markUpTo(userId: string, entryId: string, episodeId: string) {
const entry = await requireEntry(userId, entryId);
const target = await prisma.episode.findFirst({
where: { id: episodeId, titleId: entry.titleId },
select: { seasonNumber: true, episodeNumber: true },
});
if (!target) throw new LibraryError("Diese Episode gehört nicht zu dem Titel.");
const episodes = await prisma.episode.findMany({
where: {
titleId: entry.titleId,
seasonNumber: { gt: SPECIALS_SEASON },
OR: [
{ seasonNumber: { lt: target.seasonNumber } },
{ seasonNumber: target.seasonNumber, episodeNumber: { lte: target.episodeNumber } },
],
},
select: { id: true },
});
return markEpisodesWatched(
userId,
entryId,
episodes.map((episode) => episode.id),
);
}
/** „+1 Episode" — hakt genau die nächste offene Episode ab. */
export async function watchNextEpisode(userId: string, entryId: string): Promise<WatchResult> {
const next = await getNextEpisode(userId, entryId);
if (!next) throw new LibraryError("Es sind keine offenen Episoden mehr da.");
const result = await markEpisodesWatched(userId, entryId, [next.id]);
return {
...result,
label: `S${String(next.seasonNumber).padStart(2, "0")}E${String(next.episodeNumber).padStart(2, "0")} abgehakt`,
};
}
/** Film als gesehen markieren. */
export async function markMovieWatched(
userId: string,
entryId: string,
watchedAt = new Date(),
): Promise<WatchResult> {
const entry = await requireEntry(userId, entryId);
if (entry.title.type !== "MOVIE") throw new LibraryError("Das ist keine Filmsichtung.");
const existing = await prisma.watch.findFirst({
where: { userId, entryId, episodeId: null, run: entry.currentRun },
select: { id: true },
});
if (existing) {
return { watchIds: [], status: entry.status, label: "War schon als gesehen markiert" };
}
const watch = await prisma.watch.create({
data: {
userId,
entryId,
titleId: entry.titleId,
run: entry.currentRun,
watchedAt,
minutes: entry.title.runtime ?? 0,
},
select: { id: true },
});
const status = await reconcileStatus(userId, entryId);
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" };
}
/** Macht eine Sichtungsaktion rückgängig. */
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
await requireEntry(userId, entryId);
await prisma.watch.deleteMany({ where: { id: { in: watchIds }, userId, entryId } });
return reconcileStatus(userId, entryId);
}
/**
* Neuen Durchlauf beginnen.
*
* Die bisherigen Sichtungen bleiben stehen — sie gehören zum vorherigen
* Durchlauf und zählen weiter in die Statistik. Der Fortschritt startet neu.
*/
export async function startRewatch(userId: string, entryId: string) {
const entry = await requireEntry(userId, entryId);
return prisma.libraryEntry.update({
where: { id: entryId },
data: {
currentRun: entry.currentRun + 1,
status: "WATCHING",
startedAt: new Date(),
finishedAt: null,
},
});
}
/**
* Bringt den Status in Einklang mit den Sichtungen:
* erste Sichtung startet, die letzte schließt ab.
* Manuell gesetzte Zustände (Pausiert, Abgebrochen) bleiben unangetastet.
*/
async function reconcileStatus(userId: string, entryId: string): Promise<EntryStatus> {
const entry = await requireEntry(userId, entryId);
const { watched, total } = await countProgress(userId, entry.id, entry.titleId, entry.title.type, entry.currentRun);
if (entry.status === "PAUSED" || entry.status === "DROPPED") return entry.status;
let status: EntryStatus = entry.status;
const data: Prisma.LibraryEntryUpdateInput = {};
if (total > 0 && watched >= total) {
status = "COMPLETED";
data.finishedAt = entry.finishedAt ?? new Date();
} else if (watched > 0) {
status = "WATCHING";
data.finishedAt = null;
}
if (watched > 0 && !entry.startedAt) data.startedAt = new Date();
if (status !== entry.status || Object.keys(data).length > 0) {
await prisma.libraryEntry.update({ where: { id: entryId }, data: { ...data, status } });
}
return status;
}
// ---------------------------------------------------------------------------
// Fortschritt
// ---------------------------------------------------------------------------
async function countProgress(
userId: string,
entryId: string,
titleId: string,
type: TitleType,
run: number,
) {
if (type === "MOVIE") {
const watched = await prisma.watch.count({
where: { userId, entryId, episodeId: null, run },
});
return { watched: Math.min(watched, 1), total: 1 };
}
const [watched, total] = await Promise.all([
prisma.watch.count({
where: {
userId,
entryId,
run,
episode: { seasonNumber: { gt: SPECIALS_SEASON } },
},
}),
prisma.episode.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
]);
return { watched, total };
}
export type Progress = {
watched: number;
total: number;
percent: number;
minutesWatched: number;
/** Verbleibende Laufzeit des aktuellen Durchlaufs in Minuten. */
minutesLeft: number;
};
export async function getProgress(userId: string, entryId: string): Promise<Progress> {
const entry = await requireEntry(userId, entryId);
const { watched, total } = await countProgress(
userId,
entry.id,
entry.titleId,
entry.title.type,
entry.currentRun,
);
const [minutes, remaining] = await Promise.all([
prisma.watch.aggregate({
where: { userId, entryId, run: entry.currentRun },
_sum: { minutes: true },
}),
entry.title.type === "SERIES"
? prisma.episode.aggregate({
where: {
titleId: entry.titleId,
seasonNumber: { gt: SPECIALS_SEASON },
watches: { none: { userId, run: entry.currentRun } },
},
_sum: { runtime: true },
})
: Promise.resolve({ _sum: { runtime: null } }),
]);
const minutesLeft =
entry.title.type === "MOVIE"
? watched > 0
? 0
: (entry.title.runtime ?? 0)
: (remaining._sum.runtime ?? 0);
return {
watched,
total,
percent: total > 0 ? Math.round((watched / total) * 100) : 0,
minutesWatched: minutes._sum.minutes ?? 0,
minutesLeft,
};
}
/** Die nächste offene Episode des aktuellen Durchlaufs. */
export async function getNextEpisode(userId: string, entryId: string) {
const entry = await requireEntry(userId, entryId);
if (entry.title.type !== "SERIES") return null;
return prisma.episode.findFirst({
where: {
titleId: entry.titleId,
seasonNumber: { gt: SPECIALS_SEASON },
watches: { none: { userId, run: entry.currentRun } },
},
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: {
id: true,
name: true,
seasonNumber: true,
episodeNumber: true,
airDate: true,
runtime: true,
},
});
}
/** Die im aktuellen Durchlauf gesehenen Episoden-IDs — für die Häkchen im UI. */
export async function getWatchedEpisodeIds(userId: string, entryId: string): Promise<Set<string>> {
const entry = await prisma.libraryEntry.findFirst({
where: { id: entryId, userId },
select: { currentRun: true },
});
if (!entry) return new Set();
const watches = await prisma.watch.findMany({
where: { userId, entryId, run: entry.currentRun, episodeId: { not: null } },
select: { episodeId: true },
});
return new Set(watches.map((watch) => watch.episodeId).filter((id): id is string => id !== null));
}
+226
View File
@@ -0,0 +1,226 @@
import "server-only";
import { prisma } from "./db";
import { SPECIALS_SEASON } from "./catalog";
import type { EntryStatus, Prisma, TitleType } from "@/generated/prisma/client";
/**
* Leseabfragen für die Bibliothek.
*
* Der Fortschritt wird für eine ganze Liste in **einer** Abfrage gezählt statt
* pro Eintrag — bei 300 Titeln wären das sonst 300 Rundreisen zur Datenbank.
*/
export type LibrarySort = "aktualisiert" | "titel" | "bewertung" | "jahr" | "hinzugefuegt";
export type LibraryFilters = {
query?: string;
type?: TitleType;
status?: EntryStatus;
genre?: string;
tag?: string;
provider?: string;
sort?: LibrarySort;
};
const ORDER: Record<LibrarySort, Prisma.LibraryEntryOrderByWithRelationInput[]> = {
aktualisiert: [{ updatedAt: "desc" }],
hinzugefuegt: [{ createdAt: "desc" }],
titel: [{ title: { title: "asc" } }],
bewertung: [{ rating: { sort: "desc", nulls: "last" } }, { title: { title: "asc" } }],
jahr: [{ title: { releaseDate: { sort: "desc", nulls: "last" } } }],
};
export type LibraryItem = {
entryId: string;
titleId: string;
title: string;
type: TitleType;
posterPath: string | null;
year: number | null;
status: EntryStatus;
rating: number | null;
favorite: boolean;
currentRun: number;
whereToWatch: string | null;
tags: string[];
watched: number;
total: number;
percent: number;
};
export async function getLibrary(
userId: string,
filters: LibraryFilters = {},
): Promise<LibraryItem[]> {
const where: Prisma.LibraryEntryWhereInput = {
userId,
deletedAt: null,
...(filters.status ? { status: filters.status } : {}),
...(filters.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}),
title: {
...(filters.type ? { type: filters.type } : {}),
...(filters.genre ? { genres: { has: filters.genre } } : {}),
...(filters.provider
? { providers: { some: { provider: { name: filters.provider } } } }
: {}),
...(filters.query
? {
OR: [
{ title: { contains: filters.query, mode: "insensitive" } },
{ originalTitle: { contains: filters.query, mode: "insensitive" } },
],
}
: {}),
},
};
const entries = await prisma.libraryEntry.findMany({
where,
orderBy: ORDER[filters.sort ?? "aktualisiert"],
include: {
title: {
select: {
id: true,
title: true,
type: true,
posterPath: true,
releaseDate: true,
episodeCount: true,
},
},
tags: { include: { tag: { select: { name: true } } } },
},
});
if (entries.length === 0) return [];
const counts = await countWatchedByEntry(
userId,
entries.map((entry) => ({ id: entry.id, run: entry.currentRun })),
);
return entries.map((entry) => {
const isSeries = entry.title.type === "SERIES";
const total = isSeries ? entry.title.episodeCount : 1;
const watched = Math.min(counts.get(entry.id) ?? 0, total);
return {
entryId: entry.id,
titleId: entry.title.id,
title: entry.title.title,
type: entry.title.type,
posterPath: entry.title.posterPath,
year: entry.title.releaseDate?.getFullYear() ?? null,
status: entry.status,
rating: entry.rating,
favorite: entry.favorite,
currentRun: entry.currentRun,
whereToWatch: entry.whereToWatch,
tags: entry.tags.map((link) => link.tag.name),
watched,
total,
percent: total > 0 ? Math.round((watched / total) * 100) : 0,
};
});
}
/**
* Zählt für viele Einträge auf einmal, wie viel im jeweils aktuellen
* Durchlauf gesehen wurde. Specials bleiben außen vor.
*/
async function countWatchedByEntry(
userId: string,
entries: { id: string; run: number }[],
): Promise<Map<string, number>> {
if (entries.length === 0) return new Map();
const rows = await prisma.watch.groupBy({
by: ["entryId", "run"],
where: {
userId,
entryId: { in: entries.map((entry) => entry.id) },
OR: [{ episodeId: null }, { episode: { seasonNumber: { gt: SPECIALS_SEASON } } }],
},
_count: { _all: true },
});
const runByEntry = new Map(entries.map((entry) => [entry.id, entry.run]));
const result = new Map<string, number>();
for (const row of rows) {
if (!row.entryId) continue;
// Nur der aktuelle Durchlauf zählt für den Fortschrittsbalken.
if (runByEntry.get(row.entryId) !== row.run) continue;
result.set(row.entryId, row._count._all);
}
return result;
}
export type LibraryFacets = {
genres: string[];
tags: string[];
providers: string[];
counts: Record<EntryStatus | "ALLE", number>;
};
/** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */
export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
const [genreRows, tagRows, providerRows, statusRows, total] = await Promise.all([
prisma.$queryRaw<{ genre: string }[]>`
SELECT DISTINCT unnest(t."genres") AS genre
FROM "LibraryEntry" e
JOIN "Title" t ON t."id" = e."titleId"
WHERE e."userId" = ${userId} AND e."deletedAt" IS NULL
ORDER BY genre
`,
prisma.tag.findMany({
where: { userId, entries: { some: { entry: { deletedAt: null } } } },
orderBy: { name: "asc" },
select: { name: true },
}),
prisma.$queryRaw<{ name: string }[]>`
SELECT DISTINCT p."name"
FROM "LibraryEntry" e
JOIN "TitleProvider" tp ON tp."titleId" = e."titleId"
JOIN "WatchProvider" p ON p."id" = tp."providerId"
WHERE e."userId" = ${userId} AND e."deletedAt" IS NULL
ORDER BY p."name"
`,
prisma.libraryEntry.groupBy({
by: ["status"],
where: { userId, deletedAt: null },
_count: { _all: true },
}),
prisma.libraryEntry.count({ where: { userId, deletedAt: null } }),
]);
const counts = {
ALLE: total,
WATCHING: 0,
PAUSED: 0,
COMPLETED: 0,
DROPPED: 0,
WATCHLIST: 0,
} as Record<EntryStatus | "ALLE", number>;
for (const row of statusRows) counts[row.status] = row._count._all;
return {
genres: genreRows.map((row) => row.genre).filter(Boolean),
tags: tagRows.map((row) => row.name),
providers: providerRows.map((row) => row.name),
counts,
};
}
/** Einträge im Papierkorb, jüngste zuerst. */
export async function getTrash(userId: string) {
return prisma.libraryEntry.findMany({
where: { userId, deletedAt: { not: null } },
orderBy: { deletedAt: "desc" },
include: {
title: { select: { id: true, title: true, type: true, posterPath: true, releaseDate: true } },
},
});
}
+309
View File
@@ -0,0 +1,309 @@
import "server-only";
import { env } from "./env";
/**
* Zugriff auf die TMDB-API.
*
* Nur die Endpunkte, die Flimmer wirklich braucht — samt der Typen, die uns
* interessieren (TMDB liefert weit mehr Felder, als hier stehen). Antworten
* werden kurz zwischengespeichert, damit Tippen im Suchfeld nicht jedes
* Zeichen einzeln nach draußen trägt.
*/
const BASE = "https://api.themoviedb.org/3";
export const IMAGE_BASE = "https://image.tmdb.org/t/p";
export class TmdbNotConfiguredError extends Error {
constructor() {
super(
"Es ist kein TMDB-Schlüssel hinterlegt. Bitte TMDB_API_KEY oder TMDB_ACCESS_TOKEN setzen.",
);
this.name = "TmdbNotConfiguredError";
}
}
export class TmdbError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = "TmdbError";
}
}
// --- kleiner Antwort-Zwischenspeicher ---------------------------------------
type CacheEntry = { at: number; value: unknown };
const cache = new Map<string, CacheEntry>();
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX = 500;
function readCache<T>(key: string): T | null {
const hit = cache.get(key);
if (!hit) return null;
if (Date.now() - hit.at > CACHE_TTL_MS) {
cache.delete(key);
return null;
}
return hit.value as T;
}
function writeCache(key: string, value: unknown) {
if (cache.size >= CACHE_MAX) {
// Ältesten Eintrag verwerfen — Map bewahrt die Einfügereihenfolge.
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
cache.set(key, { at: Date.now(), value });
}
export function clearTmdbCache() {
cache.clear();
}
// --- Anfragen ---------------------------------------------------------------
async function request<T>(
path: string,
params: Record<string, string | number | undefined> = {},
): Promise<T> {
if (!env.tmdb.isConfigured) throw new TmdbNotConfiguredError();
const url = new URL(`${BASE}${path}`);
url.searchParams.set("language", env.tmdb.language);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") url.searchParams.set(key, String(value));
}
const headers: Record<string, string> = { accept: "application/json" };
if (env.tmdb.accessToken) {
headers.authorization = `Bearer ${env.tmdb.accessToken}`;
} else {
url.searchParams.set("api_key", env.tmdb.apiKey);
}
const cacheKey = url.toString();
const cached = readCache<T>(cacheKey);
if (cached) return cached;
const response = await fetch(url, { headers, cache: "no-store" });
if (!response.ok) {
let detail = response.statusText;
try {
const body = (await response.json()) as { status_message?: string };
if (body.status_message) detail = body.status_message;
} catch {
// Antwort war kein JSON — dann bleibt es beim Statustext.
}
if (response.status === 401) {
throw new TmdbError("TMDB weist den Schlüssel zurück (401).", 401);
}
if (response.status === 404) {
throw new TmdbError("Bei TMDB gibt es dazu keinen Eintrag (404).", 404);
}
throw new TmdbError(`TMDB-Anfrage fehlgeschlagen: ${detail}`, response.status);
}
const data = (await response.json()) as T;
writeCache(cacheKey, data);
return data;
}
// --- Typen (nur die genutzten Felder) ---------------------------------------
export type TmdbSearchResult = {
id: number;
media_type?: "tv" | "movie" | "person";
name?: string;
title?: string;
original_name?: string;
original_title?: string;
overview?: string;
poster_path?: string | null;
backdrop_path?: string | null;
first_air_date?: string;
release_date?: string;
vote_average?: number;
genre_ids?: number[];
popularity?: number;
};
export type TmdbPaged<T> = {
page: number;
results: T[];
total_pages: number;
total_results: number;
};
export type TmdbGenre = { id: number; name: string };
export type TmdbProvider = {
provider_id: number;
provider_name: string;
logo_path?: string | null;
display_priority?: number;
};
export type TmdbWatchProviders = {
results?: Record<
string,
{
link?: string;
flatrate?: TmdbProvider[];
rent?: TmdbProvider[];
buy?: TmdbProvider[];
ads?: TmdbProvider[];
free?: TmdbProvider[];
}
>;
};
export type TmdbMovieDetails = {
id: number;
imdb_id?: string | null;
title: string;
original_title?: string;
overview?: string | null;
tagline?: string | null;
poster_path?: string | null;
backdrop_path?: string | null;
release_date?: string;
runtime?: number | null;
status?: string;
genres?: TmdbGenre[];
original_language?: string;
homepage?: string | null;
vote_average?: number;
belongs_to_collection?: { id: number; name: string; poster_path?: string | null } | null;
"watch/providers"?: TmdbWatchProviders;
};
export type TmdbSeasonSummary = {
id: number;
season_number: number;
name: string;
overview?: string | null;
poster_path?: string | null;
air_date?: string | null;
episode_count?: number;
};
export type TmdbSeriesDetails = {
id: number;
name: string;
original_name?: string;
overview?: string | null;
tagline?: string | null;
poster_path?: string | null;
backdrop_path?: string | null;
first_air_date?: string;
last_air_date?: string | null;
episode_run_time?: number[];
status?: string;
in_production?: boolean;
genres?: TmdbGenre[];
original_language?: string;
homepage?: string | null;
vote_average?: number;
number_of_seasons?: number;
number_of_episodes?: number;
seasons?: TmdbSeasonSummary[];
external_ids?: { imdb_id?: string | null };
"watch/providers"?: TmdbWatchProviders;
};
export type TmdbEpisode = {
id: number;
season_number: number;
episode_number: number;
name: string;
overview?: string | null;
still_path?: string | null;
air_date?: string | null;
runtime?: number | null;
};
export type TmdbSeasonDetails = TmdbSeasonSummary & { episodes?: TmdbEpisode[] };
export type TmdbCollection = {
id: number;
name: string;
overview?: string | null;
poster_path?: string | null;
backdrop_path?: string | null;
parts?: (TmdbSearchResult & { media_type?: "movie" })[];
};
// --- Endpunkte --------------------------------------------------------------
/** Gemeinsame Suche über Serien und Filme (Personen werden herausgefiltert). */
export async function searchMulti(query: string, page = 1) {
const data = await request<TmdbPaged<TmdbSearchResult>>("/search/multi", {
query,
page,
include_adult: "false",
});
return {
...data,
results: data.results.filter((item) => item.media_type === "tv" || item.media_type === "movie"),
};
}
export function searchSeries(query: string, page = 1) {
return request<TmdbPaged<TmdbSearchResult>>("/search/tv", { query, page, include_adult: "false" });
}
export function searchMovies(query: string, page = 1) {
return request<TmdbPaged<TmdbSearchResult>>("/search/movie", {
query,
page,
include_adult: "false",
});
}
export function getSeries(tmdbId: number) {
return request<TmdbSeriesDetails>(`/tv/${tmdbId}`, {
append_to_response: "watch/providers,external_ids",
});
}
export function getSeason(tmdbId: number, seasonNumber: number) {
return request<TmdbSeasonDetails>(`/tv/${tmdbId}/season/${seasonNumber}`);
}
export function getMovie(tmdbId: number) {
return request<TmdbMovieDetails>(`/movie/${tmdbId}`, {
append_to_response: "watch/providers,external_ids",
});
}
export function getCollection(tmdbId: number) {
return request<TmdbCollection>(`/collection/${tmdbId}`);
}
export function getTrending(window: "day" | "week" = "week") {
return request<TmdbPaged<TmdbSearchResult>>(`/trending/all/${window}`);
}
/** Alle Anbieter einer Region — für Filterlisten und Akzentfarben. */
export function getProviderList(kind: "tv" | "movie", region = env.tmdb.region) {
return request<{ results: TmdbProvider[] }>(`/watch/providers/${kind}`, {
watch_region: region,
});
}
// --- Hilfen -----------------------------------------------------------------
/** Bildpfad zu einer vollständigen TMDB-URL. */
export function imageUrl(path: string, size = "w500"): string {
return `${IMAGE_BASE}/${size}${path.startsWith("/") ? path : `/${path}`}`;
}
/** TMDB-Datum ("2019-04-14" oder "") in ein Date oder null. */
export function parseDate(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Prüft den TMDB-Importpfad gegen nachgebildete Antworten.
*
* npm test
*
* Braucht eine erreichbare Entwicklungsdatenbank (docker-compose.dev.yml) und
* keinen TMDB-Schlüssel. Angelegt werden ausschließlich Titel mit TMDB-IDs
* ab 999000 — echte Daten bleiben unberührt und werden am Ende aufgeräumt.
*/
import "./setup";
import assert from "node:assert/strict";
import test, { after, before } from "node:test";
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID, COLLECTION_ID } from "./fixtures/tmdb";
import { prisma } from "../src/lib/db";
import {
clearCatalogCaches,
ensureTitle,
importCollection,
importMovie,
importSeries,
} from "../src/lib/catalog";
const stub = installTmdbStub();
async function cleanup() {
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
await prisma.watchProvider.deleteMany({ where: { tmdbId: { in: [8, 30, 3, 337] } } });
}
before(cleanup);
after(async () => {
await cleanup();
stub.restore();
await prisma.$disconnect();
});
test("Serie mit Staffeln und Episoden übernehmen", async () => {
const title = await importSeries(SERIES_ID, { region: "DE" });
assert.equal(title.title, "Nordlicht");
assert.equal(title.type, "SERIES");
assert.equal(title.imdbId, "tt9990010");
assert.equal(title.inProduction, true);
assert.deepEqual(title.genres, ["Drama", "Mystery"]);
assert.equal(title.runtime, 48, "durchschnittliche Episodenlänge aus episode_run_time");
// Specials zählen nicht mit: 2 reguläre Staffeln, 5 reguläre Episoden.
assert.equal(title.seasonCount, 2);
assert.equal(title.episodeCount, 5);
const seasons = await prisma.season.findMany({
where: { titleId: title.id },
orderBy: { seasonNumber: "asc" },
});
assert.deepEqual(
seasons.map((s) => s.seasonNumber),
[0, 1, 2],
"Specials werden importiert, aber nicht mitgezählt",
);
const episodes = await prisma.episode.findMany({
where: { titleId: title.id, seasonNumber: 1 },
orderBy: { episodeNumber: "asc" },
});
assert.equal(episodes.length, 3);
assert.equal(episodes[0]?.name, "Ankunft");
assert.equal(
episodes[2]?.runtime,
48,
"Episode ohne eigene Laufzeit fällt auf die Serienlänge zurück",
);
assert.equal(episodes[0]?.airDate?.toISOString().slice(0, 10), "2023-03-14");
});
test("Streamingdienste werden je Region übernommen", async () => {
const title = await prisma.title.findUniqueOrThrow({
where: { type_tmdbId: { type: "SERIES", tmdbId: SERIES_ID } },
include: { providers: { include: { provider: true } } },
});
const de = title.providers.filter((p) => p.region === "DE");
assert.equal(de.length, 3, "zwei Abo-Dienste und ein Kauf-Dienst für DE");
const flatrate = de.filter((p) => p.kind === "FLATRATE").map((p) => p.provider.name).sort();
assert.deepEqual(flatrate, ["Netflix", "WOW"]);
const netflix = de.find((p) => p.provider.name === "Netflix");
assert.equal(netflix?.provider.color, "#e50914", "bekannter Dienst bekommt seine Akzentfarbe");
const at = title.providers.filter((p) => p.region === "AT");
assert.equal(at.length, 0, "nur die angeforderte Region wird geschrieben");
});
test("erneuter Import legt nichts doppelt an", async () => {
clearCatalogCaches();
const before = await prisma.episode.count({
where: { title: { tmdbId: SERIES_ID, type: "SERIES" } },
});
await importSeries(SERIES_ID, { region: "DE" });
const after = await prisma.episode.count({
where: { title: { tmdbId: SERIES_ID, type: "SERIES" } },
});
assert.equal(after, before, "Episoden werden aktualisiert, nicht dupliziert");
const titles = await prisma.title.count({ where: { tmdbId: SERIES_ID, type: "SERIES" } });
assert.equal(titles, 1);
});
test("Film mit Collection übernehmen", async () => {
const movie = await importMovie(MOVIE_ID, "DE");
assert.equal(movie.type, "MOVIE");
assert.equal(movie.runtime, 118);
assert.equal(movie.tmdbCollectionId, COLLECTION_ID);
assert.equal(movie.tmdbCollectionName, "Vorführer-Reihe");
assert.equal(movie.releaseDate?.toISOString().slice(0, 10), "2024-10-17");
});
test("Collection wird nach Erscheinungsdatum sortiert importiert", async () => {
clearCatalogCaches();
const { collection, titles } = await importCollection(COLLECTION_ID, "DE");
assert.equal(collection.name, "Vorführer-Reihe");
assert.equal(titles.length, 2);
assert.deepEqual(
titles.map((t) => t.tmdbId),
[MOVIE_ID, MOVIE_2_ID],
"die Fixture liefert die Teile verdreht — der Import muss sortieren",
);
});
test("ensureTitle greift auf den Katalog zurück, statt erneut abzurufen", async () => {
clearCatalogCaches();
const countBefore = stub.calls.length;
await ensureTitle("SERIES", SERIES_ID, { maxAgeHours: 24, region: "DE" });
assert.equal(stub.calls.length, countBefore, "frisch importierter Titel löst keinen Abruf aus");
});
test("ensureTitle frischt veraltete Einträge auf", async () => {
clearCatalogCaches();
await prisma.title.update({
where: { type_tmdbId: { type: "SERIES", tmdbId: SERIES_ID } },
data: { syncedAt: new Date(Date.now() - 40 * 24 * 3_600_000) },
});
const countBefore = stub.calls.length;
await ensureTitle("SERIES", SERIES_ID, { maxAgeHours: 24, region: "DE" });
assert.ok(stub.calls.length > countBefore, "veralteter Eintrag wird neu geholt");
});
+289
View File
@@ -0,0 +1,289 @@
/**
* Nachgebildete TMDB-Antworten.
*
* Damit lässt sich der komplette Importpfad (Kopfdaten, Staffeln, Episoden,
* Streamingdienste, Collections) prüfen, ohne einen echten Schlüssel zu
* brauchen — und ohne bei jedem Lauf die TMDB-API zu belasten. Der Aufbau
* entspricht dem, was TMDB tatsächlich liefert; gekürzt auf die Felder, die
* Flimmer verwendet.
*/
export const SERIES_ID = 999001;
export const MOVIE_ID = 999002;
export const MOVIE_2_ID = 999003;
export const COLLECTION_ID = 999100;
export const seriesDetails = {
id: SERIES_ID,
name: "Nordlicht",
original_name: "Nordlys",
overview:
"Eine Meteorologin kehrt in ihr Heimatdorf am Polarkreis zurück und stößt auf ein Wetterphänomen, das es nicht geben dürfte.",
tagline: "Manche Lichter wollen nicht gesehen werden.",
poster_path: "/abcdef1234567890.jpg",
backdrop_path: "/bcdefa1234567890.jpg",
first_air_date: "2023-03-14",
last_air_date: "2025-05-02",
episode_run_time: [48],
status: "Returning Series",
in_production: true,
genres: [
{ id: 18, name: "Drama" },
{ id: 9648, name: "Mystery" },
],
original_language: "no",
homepage: "https://example.org/nordlicht",
vote_average: 8.1,
number_of_seasons: 2,
number_of_episodes: 5,
external_ids: { imdb_id: "tt9990010" },
seasons: [
{
id: 500000,
season_number: 0,
name: "Specials",
overview: "Zusätzliche Folgen außerhalb der Handlung.",
poster_path: null,
air_date: "2023-12-24",
episode_count: 1,
},
{
id: 500001,
season_number: 1,
name: "Staffel 1",
overview: "Die Rückkehr.",
poster_path: "/cdefab1234567890.jpg",
air_date: "2023-03-14",
episode_count: 3,
},
{
id: 500002,
season_number: 2,
name: "Staffel 2",
overview: "Der lange Winter.",
poster_path: "/defabc1234567890.jpg",
air_date: "2025-04-11",
episode_count: 2,
},
],
"watch/providers": {
results: {
DE: {
link: "https://www.themoviedb.org/tv/999001/watch?locale=DE",
flatrate: [
{ provider_id: 8, provider_name: "Netflix", logo_path: "/netflix12345678.jpg" },
{ provider_id: 30, provider_name: "WOW", logo_path: "/wowlogo123456789.jpg" },
],
buy: [
{
provider_id: 3,
provider_name: "Google Play Movies",
logo_path: "/googleplay123456.jpg",
},
],
},
AT: {
flatrate: [{ provider_id: 8, provider_name: "Netflix", logo_path: "/netflix12345678.jpg" }],
},
},
},
};
export const seasons: Record<number, unknown> = {
0: {
id: 500000,
season_number: 0,
name: "Specials",
air_date: "2023-12-24",
episodes: [
{
id: 600000,
season_number: 0,
episode_number: 1,
name: "Weihnachten am Fjord",
overview: "Eine Folge außer der Reihe.",
still_path: "/still000000000001.jpg",
air_date: "2023-12-24",
runtime: 30,
},
],
},
1: {
id: 500001,
season_number: 1,
name: "Staffel 1",
air_date: "2023-03-14",
episodes: [
{
id: 600001,
season_number: 1,
episode_number: 1,
name: "Ankunft",
overview: "Ida steigt in Bodø aus dem Zug.",
still_path: "/still000000000002.jpg",
air_date: "2023-03-14",
runtime: 52,
},
{
id: 600002,
season_number: 1,
episode_number: 2,
name: "Der Messwert",
overview: "Ein Gerät zeigt etwas Unmögliches an.",
still_path: null,
air_date: "2023-03-21",
runtime: 47,
},
{
id: 600003,
season_number: 1,
episode_number: 3,
name: "Polarnacht",
overview: "Die Sonne bleibt weg — länger als sie darf.",
still_path: null,
air_date: "2023-03-28",
// Ohne Laufzeit: muss auf episode_run_time zurückfallen.
runtime: null,
},
],
},
2: {
id: 500002,
season_number: 2,
name: "Staffel 2",
air_date: "2025-04-11",
episodes: [
{
id: 600004,
season_number: 2,
episode_number: 1,
name: "Rückkehr des Lichts",
overview: null,
still_path: null,
air_date: "2025-04-11",
runtime: 50,
},
{
id: 600005,
season_number: 2,
episode_number: 2,
name: "Der zweite Winter",
overview: null,
still_path: null,
// Noch nicht ausgestrahlt — kommt in Etappe 5 im Kalender vor.
air_date: "2099-01-08",
runtime: 50,
},
],
},
};
export const movieDetails = {
id: MOVIE_ID,
imdb_id: "tt9990020",
title: "Die Stille zwischen den Bildern",
original_title: "The Silence Between Frames",
overview: "Ein Filmvorführer entdeckt in einer alten Rolle Bilder, die dort nicht sein können.",
tagline: "24 Bilder pro Sekunde. Eins davon lügt.",
poster_path: "/movie0001234567890.jpg",
backdrop_path: "/movieback01234567.jpg",
release_date: "2024-10-17",
runtime: 118,
status: "Released",
genres: [
{ id: 27, name: "Horror" },
{ id: 53, name: "Thriller" },
],
original_language: "en",
homepage: null,
vote_average: 7.4,
belongs_to_collection: {
id: COLLECTION_ID,
name: "Vorführer-Reihe",
poster_path: "/collection00123456.jpg",
},
"watch/providers": {
results: {
DE: {
flatrate: [
{ provider_id: 337, provider_name: "Disney Plus", logo_path: "/disney1234567890.jpg" },
],
rent: [
{ provider_id: 3, provider_name: "Google Play Movies", logo_path: "/googleplay123456.jpg" },
],
},
},
},
};
export const movie2Details = {
...movieDetails,
id: MOVIE_2_ID,
imdb_id: "tt9990030",
title: "Die Stille zwischen den Bildern II",
original_title: "The Silence Between Frames II",
release_date: "2026-02-06",
runtime: 126,
poster_path: "/movie0002234567890.jpg",
"watch/providers": { results: {} },
};
export const collectionDetails = {
id: COLLECTION_ID,
name: "Vorführer-Reihe",
overview: "Zwei Filme über alte Projektoren und das, was sie zeigen.",
poster_path: "/collection00123456.jpg",
backdrop_path: null,
parts: [
// Absichtlich in falscher Reihenfolge — der Import muss nach Datum sortieren.
{ id: MOVIE_2_ID, title: movie2Details.title, release_date: "2026-02-06" },
{ id: MOVIE_ID, title: movieDetails.title, release_date: "2024-10-17" },
],
};
/**
* Ersetzt `fetch` durch eine Fassung, die aus den Fixtures antwortet.
* Liefert eine Funktion zum Zurücksetzen sowie die Liste der Aufrufe.
*/
export function installTmdbStub() {
const original = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request) => {
const url = new URL(typeof input === "string" ? input : input.toString());
calls.push(url.pathname);
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
const path = url.pathname;
if (path === `/3/tv/${SERIES_ID}`) return json(seriesDetails);
const seasonMatch = path.match(new RegExp(`^/3/tv/${SERIES_ID}/season/(\\d+)$`));
if (seasonMatch?.[1] !== undefined) {
const number = Number(seasonMatch[1]);
const season = seasons[number];
return season
? json(season)
: new Response(JSON.stringify({ status_message: "Not found" }), { status: 404 });
}
if (path === `/3/movie/${MOVIE_ID}`) return json(movieDetails);
if (path === `/3/movie/${MOVIE_2_ID}`) return json(movie2Details);
if (path === `/3/collection/${COLLECTION_ID}`) return json(collectionDetails);
return new Response(JSON.stringify({ status_message: `Kein Fixture für ${path}` }), {
status: 404,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
return {
calls,
restore() {
globalThis.fetch = original;
},
};
}
+338
View File
@@ -0,0 +1,338 @@
import "./setup";
import assert from "node:assert/strict";
import test, { after, before, beforeEach } from "node:test";
import { installTmdbStub, SERIES_ID, MOVIE_ID } from "./fixtures/tmdb";
import { prisma } from "../src/lib/db";
import { importMovie, importSeries } from "../src/lib/catalog";
import {
addToLibrary,
getNextEpisode,
getProgress,
getWatchedEpisodeIds,
markEpisodesWatched,
markMovieWatched,
markSeasonWatched,
markUpTo,
moveToTrash,
restoreFromTrash,
setStatus,
startRewatch,
undoWatches,
unmarkEpisodes,
updateEntryDetails,
watchNextEpisode,
} from "../src/lib/library";
/**
* Prüft das Kern-Tracking: Aufnehmen, Abhaken, Fortschritt, Rewatch.
* Läuft gegen die Entwicklungsdatenbank mit eigenem Testkonto und
* nachgebildeten TMDB-Daten.
*/
const stub = installTmdbStub();
const TEST_EMAIL = "test-library@flimmer.invalid";
const OTHER_EMAIL = "test-library-other@flimmer.invalid";
let userId: string;
let seriesTitleId: string;
let movieTitleId: string;
async function cleanup() {
await prisma.user.deleteMany({ where: { email: { in: [TEST_EMAIL, OTHER_EMAIL] } } });
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
}
before(async () => {
await cleanup();
const user = await prisma.user.create({
data: { email: TEST_EMAIL, name: "Testkonto", region: "DE" },
select: { id: true },
});
userId = user.id;
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
});
beforeEach(async () => {
// Jeder Test startet mit einer leeren Bibliothek, aber demselben Katalog.
await prisma.watch.deleteMany({ where: { userId } });
await prisma.libraryEntry.deleteMany({ where: { userId } });
});
after(async () => {
await cleanup();
stub.restore();
await prisma.$disconnect();
});
/** Alle regulären Episoden der Testserie, in Reihenfolge. */
async function regularEpisodes() {
return prisma.episode.findMany({
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: { id: true, seasonNumber: true, episodeNumber: true, runtime: true },
});
}
test("Titel aufnehmen ist wiederholbar und schlägt den Streamingdienst vor", async () => {
const first = await addToLibrary(userId, seriesTitleId, "WATCHLIST");
const second = await addToLibrary(userId, seriesTitleId, "WATCHING");
assert.equal(first.id, second.id, "kein zweiter Eintrag für denselben Titel");
assert.equal(first.status, "WATCHLIST");
assert.equal(
first.whereToWatch,
"Netflix",
"der erste Abo-Dienst der Region wird als Startwert übernommen",
);
});
test("Papierkorb behält den Fortschritt", async () => {
const entry = await addToLibrary(userId, seriesTitleId);
const episodes = await regularEpisodes();
await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
await moveToTrash(userId, entry.id);
const trashed = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
assert.ok(trashed.deletedAt, "liegt im Papierkorb statt gelöscht zu sein");
// Erneutes Aufnehmen belebt denselben Eintrag wieder.
const revived = await addToLibrary(userId, seriesTitleId);
assert.equal(revived.id, entry.id);
assert.equal(revived.deletedAt, null);
const progress = await getProgress(userId, entry.id);
assert.equal(progress.watched, 1, "die Sichtung hat den Umweg überlebt");
await moveToTrash(userId, entry.id);
await restoreFromTrash(userId, entry.id);
const restored = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
assert.equal(restored.deletedAt, null);
});
test("Fortschritt zählt Specials nicht mit", async () => {
const entry = await addToLibrary(userId, seriesTitleId);
const progress = await getProgress(userId, entry.id);
assert.equal(progress.total, 5, "5 reguläre Episoden, die Weihnachtsfolge zählt nicht");
assert.equal(progress.watched, 0);
assert.equal(progress.percent, 0);
const special = await prisma.episode.findFirstOrThrow({
where: { titleId: seriesTitleId, seasonNumber: 0 },
});
await markEpisodesWatched(userId, entry.id, [special.id]);
const after = await getProgress(userId, entry.id);
assert.equal(after.watched, 0, "ein Special bewegt den Fortschrittsbalken nicht");
assert.ok(after.minutesWatched > 0, "die Schauzeit wird trotzdem gezählt");
});
test("nächste Episode folgt der Reihenfolge und überspringt Specials", async () => {
const entry = await addToLibrary(userId, seriesTitleId);
const first = await getNextEpisode(userId, entry.id);
assert.equal(first?.seasonNumber, 1);
assert.equal(first?.episodeNumber, 1);
await watchNextEpisode(userId, entry.id);
const second = await getNextEpisode(userId, entry.id);
assert.equal(second?.episodeNumber, 2);
// Über die Staffelgrenze hinweg
await markSeasonWatched(
userId,
entry.id,
(await prisma.season.findFirstOrThrow({ where: { titleId: seriesTitleId, seasonNumber: 1 } }))
.id,
);
const third = await getNextEpisode(userId, entry.id);
assert.equal(third?.seasonNumber, 2, "nach Staffel 1 kommt Staffel 2");
assert.equal(third?.episodeNumber, 1);
});
test("Status ergibt sich aus den Sichtungen", async () => {
const entry = await addToLibrary(userId, seriesTitleId, "WATCHLIST");
const episodes = await regularEpisodes();
const afterFirst = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
assert.equal(afterFirst.status, "WATCHING", "die erste Episode startet die Serie");
const afterAll = await markEpisodesWatched(
userId,
entry.id,
episodes.map((episode) => episode.id),
);
assert.equal(afterAll.status, "COMPLETED", "die letzte Episode schließt sie ab");
const completed = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
assert.ok(completed.startedAt && completed.finishedAt);
await unmarkEpisodes(userId, entry.id, [episodes[0]!.id]);
const reopened = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
assert.equal(reopened.status, "WATCHING", "eine zurückgenommene Episode öffnet sie wieder");
assert.equal(reopened.finishedAt, null);
});
test("Pausiert und Abgebrochen bleiben stehen", async () => {
const entry = await addToLibrary(userId, seriesTitleId);
const episodes = await regularEpisodes();
await setStatus(userId, entry.id, "PAUSED");
const result = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
assert.equal(result.status, "PAUSED", "eine Sichtung hebt die Pause nicht selbsttätig auf");
await setStatus(userId, entry.id, "DROPPED");
const dropped = await markEpisodesWatched(userId, entry.id, [episodes[1]!.id]);
assert.equal(dropped.status, "DROPPED");
});
test("„bis hierhin gesehen\" hakt alles davor ab", async () => {
const entry = await addToLibrary(userId, seriesTitleId);
const episodes = await regularEpisodes();
// Staffel 2, Episode 1 → alles aus Staffel 1 plus diese eine
const target = episodes.find((e) => e.seasonNumber === 2 && e.episodeNumber === 1)!;
const result = await markUpTo(userId, entry.id, target.id);
assert.equal(result.watchIds.length, 4, "3 aus Staffel 1 und die Zielepisode");
const progress = await getProgress(userId, entry.id);
assert.equal(progress.watched, 4);
assert.equal(progress.percent, 80);
});
test("doppeltes Abhaken legt keine zweite Sichtung an", async () => {
const entry = await addToLibrary(userId, seriesTitleId);
const episodes = await regularEpisodes();
await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
const again = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
assert.equal(again.watchIds.length, 0);
const count = await prisma.watch.count({ where: { userId, episodeId: episodes[0]!.id } });
assert.equal(count, 1);
});
test("Rückgängig entfernt genau die eben angelegten Sichtungen", async () => {
const entry = await addToLibrary(userId, seriesTitleId);
const episodes = await regularEpisodes();
await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
const season = await markSeasonWatched(
userId,
entry.id,
(await prisma.season.findFirstOrThrow({ where: { titleId: seriesTitleId, seasonNumber: 1 } }))
.id,
);
assert.equal(season.watchIds.length, 2, "die bereits gesehene Episode ist nicht dabei");
await undoWatches(userId, entry.id, season.watchIds);
const progress = await getProgress(userId, entry.id);
assert.equal(progress.watched, 1, "die erste Episode bleibt gesehen");
});
test("Rewatch setzt den Fortschritt zurück, behält aber die Schauzeit", async () => {
const entry = await addToLibrary(userId, seriesTitleId);
const episodes = await regularEpisodes();
await markEpisodesWatched(
userId,
entry.id,
episodes.map((episode) => episode.id),
);
const firstRun = await getProgress(userId, entry.id);
assert.equal(firstRun.percent, 100);
await startRewatch(userId, entry.id);
const secondRun = await getProgress(userId, entry.id);
assert.equal(secondRun.watched, 0, "der neue Durchlauf beginnt bei null");
assert.equal(secondRun.minutesWatched, 0);
const total = await prisma.watch.aggregate({ where: { userId }, _sum: { minutes: true } });
assert.equal(
total._sum.minutes,
firstRun.minutesWatched,
"die Sichtungen des ersten Durchlaufs bleiben für die Statistik erhalten",
);
// Dieselbe Episode lässt sich im neuen Durchlauf erneut abhaken.
const result = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
assert.equal(result.watchIds.length, 1);
const watched = await getWatchedEpisodeIds(userId, entry.id);
assert.deepEqual([...watched], [episodes[0]!.id], "die Häkchen zeigen nur den aktuellen Durchlauf");
});
test("verbleibende Laufzeit schrumpft mit dem Fortschritt", async () => {
const entry = await addToLibrary(userId, seriesTitleId);
const episodes = await regularEpisodes();
const before = await getProgress(userId, entry.id);
const sum = episodes.reduce((total, episode) => total + (episode.runtime ?? 0), 0);
assert.equal(before.minutesLeft, sum);
await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
const after = await getProgress(userId, entry.id);
assert.equal(after.minutesLeft, sum - (episodes[0]!.runtime ?? 0));
assert.equal(after.minutesWatched, episodes[0]!.runtime);
});
test("Filme: gesehen, Rewatch und Laufzeit", async () => {
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
const result = await markMovieWatched(userId, entry.id);
assert.equal(result.status, "COMPLETED");
assert.equal(result.watchIds.length, 1);
const progress = await getProgress(userId, entry.id);
assert.equal(progress.percent, 100);
assert.equal(progress.minutesWatched, 118);
assert.equal(progress.minutesLeft, 0);
const again = await markMovieWatched(userId, entry.id);
assert.equal(again.watchIds.length, 0, "zweimal im selben Durchlauf zählt nicht doppelt");
await startRewatch(userId, entry.id);
await markMovieWatched(userId, entry.id);
const runs = await prisma.watch.count({ where: { userId, entryId: entry.id } });
assert.equal(runs, 2, "der zweite Durchlauf wird eigenständig gezählt");
});
test("Bewertung, Notiz und Fundort lassen sich pflegen", async () => {
const entry = await addToLibrary(userId, movieTitleId);
const updated = await updateEntryDetails(userId, entry.id, {
rating: 9,
notes: " Zweimal gesehen, beim zweiten Mal besser. ",
whereToWatch: "Blu-ray",
favorite: true,
});
assert.equal(updated.rating, 9);
assert.equal(updated.notes, "Zweimal gesehen, beim zweiten Mal besser.", "Rand wird beschnitten");
assert.equal(updated.whereToWatch, "Blu-ray");
assert.equal(updated.favorite, true);
await assert.rejects(
() => updateEntryDetails(userId, entry.id, { rating: 11 }),
/zwischen 1 und 10/,
);
});
test("fremde Einträge sind nicht erreichbar", async () => {
const other = await prisma.user.create({
data: { email: OTHER_EMAIL, name: "Andere" },
select: { id: true },
});
const entry = await addToLibrary(userId, movieTitleId);
await assert.rejects(
() => updateEntryDetails(other.id, entry.id, { rating: 1 }),
/gehört nicht zu deiner Bibliothek/,
);
await prisma.user.delete({ where: { id: other.id } });
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Testumgebung.
*
* Muss als allererster Import stehen: statische Importe werden vor jedem
* anderen Anweisungscode ausgeführt, und die Module lesen ihre Konfiguration
* beim Laden. Die Werte hier sind Platzhalter — die Tests sprechen nie mit
* der echten TMDB-API.
*/
process.env.TMDB_API_KEY = process.env.TMDB_API_KEY || "fixture-key";
process.env.TMDB_REGION = "DE";
process.env.TMDB_LANGUAGE = "de-DE";
process.env.AUTH_SECRET = process.env.AUTH_SECRET || "fixture-secret-fixture-secret-fixture";
process.env.APP_URL = process.env.APP_URL || "http://localhost:3011";
if (!process.env.DATABASE_URL) {
throw new Error(
"DATABASE_URL fehlt. Tests brauchen die Entwicklungsdatenbank:\n" +
" docker compose -f docker-compose.dev.yml up -d\n" +
"und eine .env (npm test lädt sie mit --env-file).",
);
}