Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff9d380d42 | ||
|
|
8d6f1d044a | ||
|
|
cf38bd6854 | ||
|
|
0adcccc01a | ||
|
|
f02e3597b9 | ||
|
|
a9314acbb4 | ||
|
|
bb63827bf7 | ||
|
|
b25f0473fd | ||
|
|
969353ab32 | ||
|
|
f49ac17922 |
@@ -45,6 +45,9 @@ TMDB_ACCESS_TOKEN=
|
|||||||
# Sprache der Metadaten und Region für die Streaming-Verfügbarkeit
|
# Sprache der Metadaten und Region für die Streaming-Verfügbarkeit
|
||||||
TMDB_LANGUAGE=de-DE
|
TMDB_LANGUAGE=de-DE
|
||||||
TMDB_REGION=DE
|
TMDB_REGION=DE
|
||||||
|
# Nur nötig, wenn TMDB über einen eigenen Proxy erreicht werden soll.
|
||||||
|
# TMDB_BASE_URL=https://api.themoviedb.org/3
|
||||||
|
# TMDB_IMAGE_BASE_URL=https://image.tmdb.org/t/p
|
||||||
|
|
||||||
# --- Hintergrundläufe --------------------------------------------------------
|
# --- Hintergrundläufe --------------------------------------------------------
|
||||||
# Eingebauter Scheduler (prüft laufende Serien auf neue Episoden, räumt den
|
# Eingebauter Scheduler (prüft laufende Serien auf neue Episoden, räumt den
|
||||||
|
|||||||
@@ -7,6 +7,53 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-09-01
|
||||||
|
|
||||||
|
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
|
||||||
|
— und eine bebilderte Anleitung.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Benachrichtigungen** über vier Wege: E-Mail (SMTP), ntfy, Gotify und ein
|
||||||
|
generischer Webhook, der zugleich Discord, Slack und Mattermost bedient.
|
||||||
|
Jeder Nutzer richtet seine Kanäle selbst ein, kann sie einzeln stummschalten
|
||||||
|
und mit einer Testnachricht ausprobieren. Dieselbe Meldung geht nie zweimal
|
||||||
|
raus — auch ein Fehlversuch wird protokolliert, sonst spammt der nächste Lauf
|
||||||
|
den Kanal voll, sobald er wieder erreichbar ist. Die E-Mail-Vorlage trägt
|
||||||
|
Logo und Farbwelt von Flimmer.
|
||||||
|
- **Täglicher Abgleich**: frischt laufende Serien bei TMDB auf, meldet neue
|
||||||
|
Episoden und neu verfügbare Watchlist-Titel, räumt Papierkorb und abgelaufene
|
||||||
|
Sitzungen. Eingebauter Zeitplan über `ENABLE_SCHEDULER` und `SYNC_HOUR`, von
|
||||||
|
außen anstoßbar über `/api/cron/sync` mit `CRON_SECRET`, und in der
|
||||||
|
Verwaltung per Knopfdruck. Ein einzelner Titel kann den Lauf nicht kippen.
|
||||||
|
- **Import** aus Trakt.tv (JSON, egal ob Watchlist, History, Ratings oder
|
||||||
|
Collection) und aus CSV mit Komma oder Semikolon. Der Import überschreibt
|
||||||
|
nichts: was schon in der Bibliothek liegt, bleibt unangetastet.
|
||||||
|
- **Export** der eigenen Daten als CSV oder als JSON mit allen Sichtungen und
|
||||||
|
Universen.
|
||||||
|
- **REST-API** unter `/api/v1` mit persönlichen API-Schlüsseln: Bibliothek
|
||||||
|
lesen und pflegen, Episoden abhaken und zurücknehmen, suchen, Kalender,
|
||||||
|
Statistik, Universen, Export. Beschreibung als OpenAPI 3.1 unter
|
||||||
|
`/api/v1/openapi.json`, dazu `docs/API.md` mit Beispielen.
|
||||||
|
- **Einstellungsseiten** für Profil, Region, Passwort, Benachrichtigungen,
|
||||||
|
API-Schlüssel sowie Import und Export.
|
||||||
|
- **Verwaltungsbereich**: Instanzname, Registrierung öffnen und schließen,
|
||||||
|
öffentliche Profile erlauben, Konten sperren und Rechte vergeben, Abgleich
|
||||||
|
anstoßen und der Zustand der Instanz auf einen Blick. Die letzte
|
||||||
|
Administration lässt sich nicht entmachten.
|
||||||
|
- **Öffentliche Profilseite** unter `/u/<adresse>` — Zahlen und zuletzt
|
||||||
|
Gesehenes, aber keine Notizen, keine Watchlist, keine E-Mail-Adresse.
|
||||||
|
Abschaltbar durch den Nutzer *und* instanzweit durch den Betreiber.
|
||||||
|
- **PWA-Feinschliff**: eigener Service Worker (Poster aus dem Zwischenspeicher,
|
||||||
|
Seiten zuerst aus dem Netz, Offline-Rückfall) und eine Offline-Seite.
|
||||||
|
- **`TMDB_BASE_URL`** für Instanzen hinter einem eigenen Proxy — und für den
|
||||||
|
mitgelieferten TMDB-Ersatz `scripts/tmdb-stub.mjs`, mit dem sich die Suche
|
||||||
|
ohne Schlüssel aus den Beispieldaten ausprobieren lässt.
|
||||||
|
- **Bebilderte Anleitung** `docs/ANLEITUNG.md` mit 18 Screenshots aus der
|
||||||
|
laufenden Instanz, aufgenommen von `npm run screenshots`.
|
||||||
|
- **9 weitere Tests** für CSV- und Trakt-Import, Export, das Aufräumen des
|
||||||
|
Papierkorbs und den Schutz vor Doppelversand — zusammen 49.
|
||||||
|
|
||||||
## [0.5.0] — 2026-09-01
|
## [0.5.0] — 2026-09-01
|
||||||
|
|
||||||
Übersicht, Kalender und Statistik.
|
Übersicht, Kalender und Statistik.
|
||||||
@@ -214,7 +261,8 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
|
|||||||
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
||||||
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
||||||
|
|
||||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.5.0...HEAD
|
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.6.0...HEAD
|
||||||
|
[0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
|
||||||
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
|
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
|
||||||
[0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
|
[0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
|
||||||
[0.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0
|
[0.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0
|
||||||
|
|||||||
@@ -100,8 +100,25 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
|||||||
|
|
||||||
## Fortschritt
|
## Fortschritt
|
||||||
|
|
||||||
- ✅ **Etappe 1** (v0.1.0, released) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
|
- ✅ **Etappe 1** (v0.1.0) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
|
||||||
- ⏳ **Etappe 2** — TMDB-Anbindung
|
- ✅ **Etappe 2** (v0.2.0) — TMDB: Suche, Import, Episoden, Bild-Cache. 14 min.
|
||||||
|
- ✅ **Etappe 3** (v0.3.0) — Bibliothek, Status, Episodenfortschritt, Papierkorb. 24 min.
|
||||||
|
- ✅ **Etappe 4** (v0.4.0) — Universen mit Reihenfolgen, Freigaben. 14 min.
|
||||||
|
- ✅ **Etappe 5** (v0.5.0) — Übersicht, Kalender, Statistik. 15 min.
|
||||||
|
- ✅ **Etappe 6** (v0.6.0) — Benachrichtigungen, Abgleich, Import/Export, REST-API, PWA, Anleitung. 31 min.
|
||||||
|
|
||||||
|
**Alle sechs Etappen abgeschlossen. Gesamtzeit: 2 h 16 min.**
|
||||||
|
|
||||||
|
### Nützliches für spätere Arbeiten
|
||||||
|
- Suche ohne TMDB-Schlüssel ausprobieren:
|
||||||
|
`npm run tmdb:stub` und dann
|
||||||
|
`TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 PORT=3011 npm run dev`
|
||||||
|
- Screenshots neu aufnehmen: `npm run screenshots` (nimmt auf und verkleinert)
|
||||||
|
- `npm test` läuft seriell gegen die Dev-Datenbank, braucht keinen TMDB-Schlüssel
|
||||||
|
- Deutsche Anführungszeichen (`„…"`) niemals in JS-**String-Literalen** —
|
||||||
|
das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok.
|
||||||
|
- Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das
|
||||||
|
Write-Werkzeug benutzen.
|
||||||
|
|
||||||
<!-- BEGIN:nextjs-agent-rules -->
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,22 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Etappe 6 — Benachrichtigungen, Sync, Import/Export, REST-API, PWA (v0.6.0)
|
||||||
|
|
||||||
|
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 6.1 | 19:54 | 19:58 | 4 min | Benachrichtigungen über vier Wege samt E-Mail-Vorlage; Hintergrundlauf mit Zeitplan und Cron-Endpunkt |
|
||||||
|
| 6.2 | 19:58 | 20:02 | 4 min | Import (Trakt, CSV) und Export (CSV, JSON) |
|
||||||
|
| 6.3 | 20:02 | 20:07 | 5 min | REST-API `/api/v1` mit API-Schlüsseln, OpenAPI-Beschreibung |
|
||||||
|
| 6.4 | 20:07 | 20:12 | 5 min | Einstellungsseiten, Verwaltung, öffentliches Profil; Service Worker und Offline-Seite |
|
||||||
|
| 6.5 | 20:12 | 20:15 | 3 min | 9 Tests für Import, Export, Papierkorb und Doppelversandschutz |
|
||||||
|
| 6.6 | 20:15 | 20:20 | 5 min | TMDB-Ersatz für Screenshots, 18 Aufnahmen, Verkleinerung auf WebP (13 MB → 0,6 MB) |
|
||||||
|
| 6.7 | 20:20 | 20:25 | 5 min | `docs/ANLEITUNG.md` und `docs/API.md`, README erweitert, API von Hand durchgetestet |
|
||||||
|
|
||||||
|
**Etappe 6 gesamt: 31 min** (2026-09-01, 19:54 – 20:25)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Gesamtübersicht
|
## Gesamtübersicht
|
||||||
|
|
||||||
| Etappe | Version | Dauer |
|
| Etappe | Version | Dauer |
|
||||||
@@ -84,5 +100,27 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
|||||||
| 3 — Kern-Tracking | 0.3.0 | 24 min |
|
| 3 — Kern-Tracking | 0.3.0 | 24 min |
|
||||||
| 4 — Universen | 0.4.0 | 14 min |
|
| 4 — Universen | 0.4.0 | 14 min |
|
||||||
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
|
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
|
||||||
|
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
|
||||||
|
|
||||||
**Bisher gesamt: 105 min**
|
**Gesamtentwicklungszeit: 2 h 16 min** (136 min, 2026-09-01, 18:09 – 20:25)
|
||||||
|
|
||||||
|
### Womit die Zeit verging
|
||||||
|
|
||||||
|
| Anteil | Bereich |
|
||||||
|
|---|---|
|
||||||
|
| ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) |
|
||||||
|
| ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) |
|
||||||
|
| ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) |
|
||||||
|
| ~20 min | Tests — 49 Stück, die unterwegs sieben echte Fehler gefunden haben |
|
||||||
|
| ~15 min | Dokumentation, Screenshots, Changelog, Releases |
|
||||||
|
| ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette |
|
||||||
|
|
||||||
|
### Fehler, die die Tests gefunden haben
|
||||||
|
|
||||||
|
1. `importSeries` gab veraltete Staffel- und Episodenzähler zurück.
|
||||||
|
2. Testdateien räumten sich im parallelen Lauf gegenseitig die Fixtures weg.
|
||||||
|
3. Das Monatsdiagramm blieb wegen eines Zeitzonenversatzes leer.
|
||||||
|
4. Abgeschlossene, aber weiterlaufende Serien verschwanden aus dem Kalender.
|
||||||
|
5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel.
|
||||||
|
6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build.
|
||||||
|
7. Der SVG-Titel eines Diagramms brach die Hydration.
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ der Flimmerkiste und dem warmen Flimmern, das ein Bildschirm im dunklen Zimmer
|
|||||||
verbreitet. Metadaten kommen von [TMDB](https://www.themoviedb.org/), die Daten
|
verbreitet. Metadaten kommen von [TMDB](https://www.themoviedb.org/), die Daten
|
||||||
bleiben auf deinem Server.
|
bleiben auf deinem Server.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/02-uebersicht.webp" alt="Die Übersicht von Flimmer" width="820">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> **Bebilderte Anleitung:** [docs/ANLEITUNG.md](docs/ANLEITUNG.md) —
|
||||||
|
> führt einmal durch die ganze Anwendung.
|
||||||
|
> **Schnittstelle:** [docs/API.md](docs/API.md).
|
||||||
|
|
||||||
## Was Flimmer kann
|
## Was Flimmer kann
|
||||||
|
|
||||||
- **Tracking** — Serien und Filme mit Status (Schaue gerade, Pausiert,
|
- **Tracking** — Serien und Filme mit Status (Schaue gerade, Pausiert,
|
||||||
@@ -29,8 +37,13 @@ bleiben auf deinem Server.
|
|||||||
Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar.
|
Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar.
|
||||||
- **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen
|
- **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen
|
||||||
sich teilen. Der erste registrierte Zugang wird Administrator.
|
sich teilen. Der erste registrierte Zugang wird Administrator.
|
||||||
|
- **Übersicht, Kalender und Statistik** — was als Nächstes dran ist, wann welche
|
||||||
|
Episode läuft, und wie viel Zeit wohin geflossen ist.
|
||||||
|
- **Import und Export** — aus Trakt.tv oder CSV hinein, als CSV oder JSON hinaus.
|
||||||
- **REST-API und PWA** — dokumentierte Schnittstelle mit eigenen API-Schlüsseln,
|
- **REST-API und PWA** — dokumentierte Schnittstelle mit eigenen API-Schlüsseln,
|
||||||
installierbar auf dem Startbildschirm.
|
installierbar auf dem Startbildschirm, mit Offline-Rückfall.
|
||||||
|
- **Papierkorb statt Löschen** — 30 Tage Bedenkzeit, und jede Sichtung lässt sich
|
||||||
|
mit einem Klick zurücknehmen.
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
@@ -165,22 +178,49 @@ npm run dev
|
|||||||
| `npm run db:migrate` | Migration erzeugen und anwenden |
|
| `npm run db:migrate` | Migration erzeugen und anwenden |
|
||||||
| `npm run db:studio` | Prisma Studio |
|
| `npm run db:studio` | Prisma Studio |
|
||||||
| `npm run db:seed` | Beispieldaten |
|
| `npm run db:seed` | Beispieldaten |
|
||||||
|
| `npm test` | Testlauf gegen die Entwicklungsdatenbank (braucht keinen TMDB-Schlüssel) |
|
||||||
| `npm run icons` | Favicon und PWA-Icons aus dem SVG-Logo neu erzeugen |
|
| `npm run icons` | Favicon und PWA-Icons aus dem SVG-Logo neu erzeugen |
|
||||||
|
| `npm run screenshots` | Screenshots der Anleitung neu aufnehmen |
|
||||||
|
|
||||||
|
Ohne TMDB-Schlüssel lässt sich die Suche mit einem lokalen Ersatz ausprobieren,
|
||||||
|
der aus den Beispieldaten antwortet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --import tsx scripts/tmdb-stub.mjs 3099
|
||||||
|
TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
Aufbau des Projekts:
|
Aufbau des Projekts:
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
app/ Routen (App Router), API-Endpunkte
|
app/ Routen (App Router), API-Endpunkte, Server-Aktionen
|
||||||
components/ Wiederverwendbare Bausteine
|
components/ Wiederverwendbare Bausteine
|
||||||
lib/ Serverseitige Logik: DB, Auth, TMDB, Benachrichtigungen
|
lib/ Serverseitige Logik: DB, Auth, TMDB, Tracking, Statistik,
|
||||||
|
Universen, Benachrichtigungen, Abgleich, Import/Export
|
||||||
i18n/ Oberflächentexte
|
i18n/ Oberflächentexte
|
||||||
prisma/ Schema, Migrationen, Seed
|
prisma/ Schema, Migrationen, Seed samt Beispielkatalog
|
||||||
|
tests/ Testlauf mit nachgebildeten TMDB-Antworten
|
||||||
public/brand/ Logo in allen Varianten
|
public/brand/ Logo in allen Varianten
|
||||||
docker/ Entrypoint des Containers
|
docker/ Entrypoint des Containers
|
||||||
docs/ Anleitung, Design-Dokumentation
|
docs/ Anleitung mit Screenshots, API, Design-Dokumentation
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Hintergrundlauf
|
||||||
|
|
||||||
|
Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue
|
||||||
|
Episoden, prüft die Verfügbarkeit von Watchlist-Titeln und leert den
|
||||||
|
Papierkorb. Gesteuert wird das über `ENABLE_SCHEDULER` und `SYNC_HOUR`.
|
||||||
|
|
||||||
|
Wer lieber einen eigenen Cron benutzt, setzt `ENABLE_SCHEDULER=false`, vergibt
|
||||||
|
ein `CRON_SECRET` und ruft auf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://flimmer.example.com/api/cron/sync
|
||||||
|
```
|
||||||
|
|
||||||
|
In der Verwaltung lässt sich der Lauf jederzeit von Hand anstoßen.
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
Next.js (App Router) mit TypeScript, PostgreSQL über Prisma, Tailwind CSS.
|
Next.js (App Router) mit TypeScript, PostgreSQL über Prisma, Tailwind CSS.
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="../public/brand/flimmer-logo.svg" alt="Flimmer" width="320">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# Flimmer — Anleitung
|
||||||
|
|
||||||
|
Diese Anleitung führt einmal durch die ganze Anwendung: vom ersten Konto über
|
||||||
|
das tägliche Abhaken bis zu Universen, Statistik und Schnittstelle.
|
||||||
|
|
||||||
|
Die Bilder stammen aus einer Instanz mit Beispieldaten (`npm run db:seed`).
|
||||||
|
Die Poster darin sind erfunden — mit hinterlegtem TMDB-Schlüssel stehen dort
|
||||||
|
die echten.
|
||||||
|
|
||||||
|
## Inhalt
|
||||||
|
|
||||||
|
1. [Erster Start](#erster-start)
|
||||||
|
2. [Die Übersicht](#die-übersicht)
|
||||||
|
3. [Titel finden und aufnehmen](#titel-finden-und-aufnehmen)
|
||||||
|
4. [Eine Serie verfolgen](#eine-serie-verfolgen)
|
||||||
|
5. [Die Bibliothek](#die-bibliothek)
|
||||||
|
6. [Universen](#universen)
|
||||||
|
7. [Kalender](#kalender)
|
||||||
|
8. [Statistik](#statistik)
|
||||||
|
9. [Einstellungen](#einstellungen)
|
||||||
|
10. [Benachrichtigungen](#benachrichtigungen)
|
||||||
|
11. [Import und Export](#import-und-export)
|
||||||
|
12. [API-Schlüssel](#api-schlüssel)
|
||||||
|
13. [Papierkorb](#papierkorb)
|
||||||
|
14. [Verwaltung](#verwaltung)
|
||||||
|
15. [Auf dem Handy](#auf-dem-handy)
|
||||||
|
16. [Helles Farbschema](#helles-farbschema)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erster Start
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Nach der Installation ruf die Adresse deiner Instanz auf. Solange es noch kein
|
||||||
|
einziges Konto gibt, ist die Registrierung offen — **der erste Zugang wird
|
||||||
|
automatisch Administrator**. Lege ihn also gleich für dich an.
|
||||||
|
|
||||||
|
Danach kannst du die Registrierung in der [Verwaltung](#verwaltung) schließen,
|
||||||
|
damit sich niemand sonst anmeldet.
|
||||||
|
|
||||||
|
> Ist SSO eingerichtet (`OIDC_*` in der `.env`), erscheint hier zusätzlich ein
|
||||||
|
> Knopf für die Anmeldung über den eigenen Anbieter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Übersicht
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Startseite beantwortet drei Fragen:
|
||||||
|
|
||||||
|
- **Weiterschauen** — welche Serien laufen gerade, und welche Episode ist als
|
||||||
|
Nächstes dran. Der Knopf **+1 Episode** hakt genau diese Folge ab; du musst
|
||||||
|
die Serie dafür nicht öffnen.
|
||||||
|
- **Demnächst** — welche Episoden deiner Serien in den nächsten vier Wochen
|
||||||
|
ausgestrahlt werden.
|
||||||
|
- **Neu verfügbar** — welche Titel von deiner Watchlist zuletzt auf einem
|
||||||
|
Streamingdienst in deiner Region aufgetaucht sind.
|
||||||
|
|
||||||
|
Ist die Bibliothek noch leer, steht hier stattdessen der Weg zur Suche.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Titel finden und aufnehmen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Über **Suchen** durchsuchst du TMDB. Die Treffer lassen sich auf *Alles*,
|
||||||
|
*Serien* oder *Filme* eingrenzen; die Suchanfrage steht in der Adresszeile und
|
||||||
|
ist damit teilbar.
|
||||||
|
|
||||||
|
Ein Klick auf einen Treffer holt die vollständigen Daten — bei Serien alle
|
||||||
|
Staffeln und Episoden — und öffnet die Detailseite. Von dort nimmst du den
|
||||||
|
Titel auf, wahlweise als **Ich schaue das gerade** oder **Auf die Watchlist**.
|
||||||
|
|
||||||
|
> **Nichts dabei?** Unter *Titel selbst anlegen* trägst du etwas ein, das TMDB
|
||||||
|
> nicht kennt — Heimvideos, Vorträge, regionale Produktionen. Solche Titel
|
||||||
|
> werden vom automatischen Abgleich nie angefasst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Eine Serie verfolgen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Detailseite ist der Ort, an dem du eine Serie führst:
|
||||||
|
|
||||||
|
- **Fortschrittsbalken** mit gesehenen Episoden und verbleibender Laufzeit,
|
||||||
|
berechnet aus der echten Episodenanzahl.
|
||||||
|
- **Als Nächstes dran** nennt Staffel, Episode, Titel und Ausstrahlungsdatum.
|
||||||
|
Der Knopf **+1 Episode** hakt sie ab.
|
||||||
|
- Der **Status** (Schaue gerade, Watchlist, Pausiert, Abgeschlossen,
|
||||||
|
Abgebrochen) ergibt sich beim Abhaken von selbst: die erste Episode startet
|
||||||
|
die Serie, die letzte schließt sie ab. Pausiert und Abgebrochen bleiben
|
||||||
|
stehen — wer bewusst pausiert hat, will das nicht durch ein Häkchen
|
||||||
|
aufgehoben bekommen.
|
||||||
|
- In der **Staffelliste** hakst du einzelne Episoden ab, ganze Staffeln auf
|
||||||
|
einmal, oder über *Bis hierhin gesehen* alles Vorherige. Beim Abhaken glimmt
|
||||||
|
die Zeile kurz warm auf.
|
||||||
|
- Rechts stehen **Bewertung** (1 bis 10), **wo du das schaust** (mit Vorschlag
|
||||||
|
aus der Streaming-Verfügbarkeit), **Tags** und ein **Notizfeld**.
|
||||||
|
|
||||||
|
Jede Sichtung meldet sich unten kurz zurück und lässt sich mit **Rückgängig**
|
||||||
|
zurücknehmen.
|
||||||
|
|
||||||
|
Ist eine Serie durch, erscheint **Noch einmal schauen** — der Fortschritt
|
||||||
|
beginnt von vorn, die alten Sichtungen bleiben für die Statistik erhalten. Die
|
||||||
|
Karte zeigt dann „2. Durchlauf".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Die Bibliothek
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Alles, was du verfolgst, als Posterraster. Jede Kachel zeigt Status,
|
||||||
|
Bewertung und — bei Serien — den Fortschritt.
|
||||||
|
|
||||||
|
Filtern kannst du nach:
|
||||||
|
|
||||||
|
| Filter | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| Volltextsuche | Titel und Originaltitel |
|
||||||
|
| Status | Schaue gerade, Watchlist, Pausiert, Abgeschlossen, Abgebrochen |
|
||||||
|
| Typ | nur Serien oder nur Filme |
|
||||||
|
| Genre | alle Genres, die in deiner Bibliothek vorkommen |
|
||||||
|
| Dienst | Streamingdienste deiner Titel |
|
||||||
|
| Tag | deine eigenen Schlagworte |
|
||||||
|
|
||||||
|
Sortieren geht nach zuletzt aktualisiert, zuletzt hinzugefügt, Titel, Bewertung
|
||||||
|
oder Erscheinungsjahr. Die gesamte Auswahl steht in der Adresszeile — ein
|
||||||
|
Lesezeichen auf „alle pausierten Krimis" ist damit möglich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Universen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Ein Universum bündelt Titel, die zusammengehören — MCU, Star Wars, Wizarding
|
||||||
|
World — und zwar **Filme und Serien gemeinsam**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Auf der Detailseite steht oben der Gesamtfortschritt: wie viel abgeschlossen
|
||||||
|
ist, wie viel Zeit schon drinsteckt und wie lange es bis zum Ende noch dauert.
|
||||||
|
Der erste noch offene Eintrag ist als **Als Nächstes dran** hervorgehoben.
|
||||||
|
|
||||||
|
**Mehrere Reihenfolgen** sind der Kern der Sache:
|
||||||
|
|
||||||
|
- Die **Erscheinungsreihenfolge** ist immer da und wird aus den
|
||||||
|
Erscheinungsdaten berechnet — sie stimmt damit auch, wenn später etwas
|
||||||
|
dazukommt.
|
||||||
|
- Über **+ Reihenfolge** legst du weitere an, etwa *Chronologisch* oder
|
||||||
|
*Für Einsteiger*. Sortiert wird per Ziehen oder über die Pfeile neben jedem
|
||||||
|
Eintrag; gespeichert wird sofort.
|
||||||
|
|
||||||
|
Titel nimmst du rechts aus deiner Bibliothek auf. Gehört ein Film zu einer
|
||||||
|
TMDB-Reihe (etwa „Spider-Man Collection"), lässt sich die ganze Reihe mit einem
|
||||||
|
Klick übernehmen.
|
||||||
|
|
||||||
|
**Teilen** geht mit anderen Konten derselben Instanz, wahlweise nur zum Ansehen
|
||||||
|
oder zum gemeinsamen Pflegen. Der Fortschritt bleibt dabei persönlich: jede
|
||||||
|
Person sieht ihren eigenen Stand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kalender
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Das Monatsraster zeigt, wann Episoden deiner Serien laufen. Tage mit Terminen
|
||||||
|
sind hinterlegt, der heutige Tag trägt den Signaturverlauf. Unter dem Raster
|
||||||
|
stehen dieselben Termine noch einmal als Liste mit vollem Datum.
|
||||||
|
|
||||||
|
Mit den Pfeilen blätterst du durch die Monate, **Heute** springt zurück.
|
||||||
|
|
||||||
|
Abgebrochene Serien tauchen hier nicht auf. Abgeschlossene schon — wer bei
|
||||||
|
einer laufenden Serie auf dem Stand ist, will die nächste Folge erst recht
|
||||||
|
sehen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statistik
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Oben die **Gesamtschauzeit** in Stunden und umgerechnet in Tage am Stück, daneben
|
||||||
|
Episoden, Filme und abgeschlossene Titel.
|
||||||
|
|
||||||
|
- **Die letzten zwölf Monate** — abgehakte Episoden und Filme je Monat.
|
||||||
|
Beim Überfahren einer Säule erscheinen die genauen Zahlen samt Schauzeit.
|
||||||
|
- **Nach Streamingdienst** — wo du tatsächlich geschaut hast. Grundlage ist das
|
||||||
|
Feld *wo schaue ich das*, nicht die bloße Verfügbarkeit.
|
||||||
|
- **Nach Genre** — Titel mit mehreren Genres zählen in jedem mit.
|
||||||
|
- **Jahr in Zahlen** — Schauzeit, Episoden, Filme, neu Angefangenes,
|
||||||
|
Abgeschlossenes, der stärkste Monat und der Titel mit der meisten Zeit.
|
||||||
|
Über die Jahreszahlen wechselst du das Jahr.
|
||||||
|
- Darunter **wo die meiste Zeit hinging** und **deine besten Bewertungen**.
|
||||||
|
|
||||||
|
Alles ist aus den Sichtungen gerechnet; Rewatches zählen mit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Einstellungen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Hier stellst du Anzeigename und **Region** ein — Letztere bestimmt, welche
|
||||||
|
Streamingdienste angezeigt werden (Standard: DE).
|
||||||
|
|
||||||
|
Darunter legst du fest, **wovon Flimmer dir berichten soll**: neue Episoden,
|
||||||
|
neue Releases, neu verfügbare Watchlist-Titel.
|
||||||
|
|
||||||
|
Wenn der Betreiber öffentliche Profile erlaubt, kannst du deins freischalten
|
||||||
|
und eine Adresse wählen. Die Profilseite zeigt Zahlen und zuletzt Gesehenes —
|
||||||
|
**keine** Notizen, keine Watchlist, keine E-Mail-Adresse.
|
||||||
|
|
||||||
|
Das Passwort änderst du im Abschnitt darunter; danach wirst du auf allen
|
||||||
|
Geräten abgemeldet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benachrichtigungen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Wovon* berichtet wird, steht im Profil — *wohin*, hier. Vier Wege stehen zur
|
||||||
|
Wahl:
|
||||||
|
|
||||||
|
| Kanal | Nötige Angaben |
|
||||||
|
|---|---|
|
||||||
|
| **E-Mail** | nichts (oder eine abweichende Adresse); der SMTP-Zugang kommt vom Betreiber |
|
||||||
|
| **ntfy** | Server (Standard `https://ntfy.sh`) und Topic, optional ein Token |
|
||||||
|
| **Gotify** | Serveradresse und Anwendungs-Token |
|
||||||
|
| **Webhook** | die Zieladresse — funktioniert mit Discord, Slack und Mattermost |
|
||||||
|
|
||||||
|
Jeder Kanal lässt sich einzeln stummschalten und mit **Testen** ausprobieren.
|
||||||
|
Unten steht, was zuletzt verschickt wurde — auch fehlgeschlagene Versuche mit
|
||||||
|
Grund.
|
||||||
|
|
||||||
|
Dieselbe Meldung kommt nie zweimal: Flimmer merkt sich, was schon draußen ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Import und Export
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Import** aus zwei Quellen:
|
||||||
|
|
||||||
|
- **CSV**, wie es Tabellenprogramme und viele Tracker ausgeben. Die Kopfzeile
|
||||||
|
braucht mindestens eine Titelspalte (`Titel`, `title`, `name` oder `show`);
|
||||||
|
erkannt werden außerdem Typ, Status, Bewertung, TMDB-ID, Jahr und Notiz.
|
||||||
|
Komma und Semikolon als Trennzeichen funktionieren beide.
|
||||||
|
- **Trakt.tv** — ein JSON-Export aus Watchlist, History, Ratings oder
|
||||||
|
Collection. Flimmer sucht sich die Einträge selbst heraus.
|
||||||
|
|
||||||
|
Der Import **überschreibt nichts**. Titel, die schon in deiner Bibliothek
|
||||||
|
liegen, bleiben unangetastet; es kommt nur dazu, was fehlt.
|
||||||
|
|
||||||
|
**Export** als JSON (mit allen Sichtungen und Universen) oder als CSV für
|
||||||
|
Tabellenkalkulationen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API-Schlüssel
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Für Skripte und eigene Anwendungen. Ein Schlüssel ist **einmalig** sichtbar —
|
||||||
|
danach liegt in der Datenbank nur ein Hash. Er hat dieselben Rechte wie dein
|
||||||
|
Konto; zieh ihn zurück, sobald du ihn nicht mehr brauchst.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer flm_…" \
|
||||||
|
https://flimmer.example.com/api/v1/bibliothek?status=WATCHING
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Endpunkte stehen in [`API.md`](API.md); die maschinenlesbare Fassung
|
||||||
|
liegt unter `/api/v1/openapi.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Papierkorb
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Entfernte Titel landen für **30 Tage** im Papierkorb und behalten dabei ihren
|
||||||
|
Fortschritt. Von dort lassen sie sich wiederherstellen oder sofort endgültig
|
||||||
|
löschen. Nimmst du denselben Titel erneut auf, lebt der alte Eintrag samt
|
||||||
|
Sichtungen wieder auf.
|
||||||
|
|
||||||
|
Nach Ablauf der Frist räumt der tägliche Hintergrundlauf auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verwaltung
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Nur für Administratoren. Hier lässt sich
|
||||||
|
|
||||||
|
- der **Name der Instanz** ändern,
|
||||||
|
- die **Registrierung** öffnen oder schließen,
|
||||||
|
- **öffentliche Profile** instanzweit erlauben oder abschalten,
|
||||||
|
- der **Abgleich** von Hand anstoßen,
|
||||||
|
- und der Zustand der Instanz ablesen: Anzahl Konten, Umfang des Katalogs und
|
||||||
|
ob TMDB, SMTP, Zeitplan und OIDC eingerichtet sind.
|
||||||
|
|
||||||
|
In der Kontenliste vergibst du Verwaltungsrechte und sperrst Konten. Ein
|
||||||
|
gesperrtes Konto wird sofort von allen Geräten abgemeldet. Die letzte
|
||||||
|
Administration lässt sich nicht entmachten, und sich selbst kann niemand
|
||||||
|
sperren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auf dem Handy
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Oberfläche ist für schmale Geräte ausgelegt: die Navigation rutscht in eine
|
||||||
|
eigene, wischbare Zeile, Karten stapeln sich untereinander, und der Knopf
|
||||||
|
**+1 Episode** bleibt groß genug für den Daumen.
|
||||||
|
|
||||||
|
Flimmer ist als **PWA** installierbar — im Browsermenü *Zum Startbildschirm
|
||||||
|
hinzufügen*. Danach startet die App im eigenen Fenster ohne Adresszeile.
|
||||||
|
Bereits geladene Seiten und Poster bleiben auch ohne Verbindung sichtbar;
|
||||||
|
statt einer Fehlerseite erscheint dann ein Hinweis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Helles Farbschema
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Über den Knopf im Kopfbereich wechselst du zwischen **Dunkel**, **Hell** und
|
||||||
|
**System**. Die Wahl bleibt auf dem Gerät gespeichert und greift, bevor die
|
||||||
|
Seite gezeichnet wird — es blitzt nichts auf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Und wenn etwas fehlt?
|
||||||
|
|
||||||
|
- **Die Suche findet nichts** → Es fehlt der TMDB-Schlüssel. Trag
|
||||||
|
`TMDB_API_KEY` in die `.env` ein und starte die Instanz neu.
|
||||||
|
- **Keine Streamingdienste** → Prüfe die Region in den Einstellungen. TMDB hat
|
||||||
|
nicht für jedes Land Daten zu jedem Titel.
|
||||||
|
- **Keine Benachrichtigungen** → Erst im Profil auswählen, *wovon* berichtet
|
||||||
|
werden soll, dann einen Kanal anlegen und mit *Testen* prüfen.
|
||||||
|
- **Neue Episoden fehlen** → Der Abgleich läuft einmal täglich. In der
|
||||||
|
Verwaltung lässt er sich sofort anstoßen.
|
||||||
|
|
||||||
|
Alles Weitere zur Installation, zum Aktualisieren und zum Backup steht in der
|
||||||
|
[README](../README.md).
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Flimmer — REST-API
|
||||||
|
|
||||||
|
Alles, was die Oberfläche kann, geht auch über die Schnittstelle: Bibliothek
|
||||||
|
lesen und pflegen, Episoden abhaken, suchen, Kalender und Statistik abrufen,
|
||||||
|
Universen verwalten, Daten exportieren.
|
||||||
|
|
||||||
|
Die maschinenlesbare Beschreibung liegt unter
|
||||||
|
`GET /api/v1/openapi.json` (OpenAPI 3.1) — damit lassen sich Clients erzeugen
|
||||||
|
oder die Endpunkte in Insomnia, Bruno oder Postman importieren.
|
||||||
|
|
||||||
|
## Anmeldung
|
||||||
|
|
||||||
|
Jede Anfrage trägt einen persönlichen API-Schlüssel im `Authorization`-Kopf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer flm_a1b2c3d4.xxxxxxxxxxxx" \
|
||||||
|
https://flimmer.example.com/api/v1/me
|
||||||
|
```
|
||||||
|
|
||||||
|
Schlüssel legst du unter **Einstellungen → API-Schlüssel** an. Der Klartext ist
|
||||||
|
**einmalig** sichtbar; danach liegt in der Datenbank nur ein Hash. Ein Schlüssel
|
||||||
|
hat dieselben Rechte wie dein Konto — behandle ihn wie ein Passwort und zieh ihn
|
||||||
|
zurück, sobald du ihn nicht mehr brauchst.
|
||||||
|
|
||||||
|
Aus dem Browser heraus funktioniert stattdessen das Sitzungscookie; dieselben
|
||||||
|
Endpunkte bedienen also Skripte und das eigene Frontend.
|
||||||
|
|
||||||
|
## Antwortformat
|
||||||
|
|
||||||
|
Alle Antworten sind JSON. Zeitangaben in ISO 8601, Laufzeiten in Minuten.
|
||||||
|
Fehler tragen immer ein Feld `error`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "error": "Diesen Eintrag gibt es nicht." }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Status | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| 200 | In Ordnung |
|
||||||
|
| 401 | Kein oder ungültiger Schlüssel |
|
||||||
|
| 404 | Nicht vorhanden (oder nicht deins) |
|
||||||
|
| 422 | Anfrage unvollständig — `details` nennt die Felder |
|
||||||
|
| 502 / 503 | TMDB nicht erreichbar bzw. kein Schlüssel hinterlegt |
|
||||||
|
|
||||||
|
## Endpunkte
|
||||||
|
|
||||||
|
### Konto
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/me
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bibliothek
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/bibliothek?status=WATCHING&typ=SERIES&genre=Drama&q=nord&limit=50&offset=0
|
||||||
|
POST /api/v1/bibliothek
|
||||||
|
```
|
||||||
|
|
||||||
|
Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `limit`, `offset`.
|
||||||
|
Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
|
||||||
|
|
||||||
|
Aufnehmen — entweder mit interner Kennung oder direkt mit der TMDB-Kennung:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||||
|
-d '{"tmdbId": 1396, "type": "SERIES", "status": "WATCHING"}' \
|
||||||
|
https://flimmer.example.com/api/v1/bibliothek
|
||||||
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/bibliothek/{entryId}
|
||||||
|
PATCH /api/v1/bibliothek/{entryId}
|
||||||
|
DELETE /api/v1/bibliothek/{entryId}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET` liefert den Eintrag samt `progress` und `nextEpisode`.
|
||||||
|
`PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite`.
|
||||||
|
`DELETE` legt den Eintrag in den Papierkorb — dort bleibt er 30 Tage.
|
||||||
|
|
||||||
|
### Sichtungen
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/bibliothek/{entryId}/sichtungen
|
||||||
|
POST /api/v1/bibliothek/{entryId}/sichtungen
|
||||||
|
DELETE /api/v1/bibliothek/{entryId}/sichtungen
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST` kennt fünf Formen:
|
||||||
|
|
||||||
|
| Rumpf | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `{"action":"next"}` | Die nächste offene Episode abhaken |
|
||||||
|
| `{"action":"movie"}` | Einen Film als gesehen markieren |
|
||||||
|
| `{"action":"episodes","episodeIds":["…"]}` | Eine Auswahl abhaken |
|
||||||
|
| `{"action":"season","seasonId":"…"}` | Eine ganze Staffel |
|
||||||
|
| `{"action":"upTo","episodeId":"…"}` | Alles bis einschließlich dieser Episode |
|
||||||
|
|
||||||
|
Die Antwort trägt `watchIds` — damit lässt sich genau diese Aktion zurücknehmen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nächste Episode abhaken
|
||||||
|
IDS=$(curl -s -X POST -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||||
|
-d '{"action":"next"}' \
|
||||||
|
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen | jq -c .watchIds)
|
||||||
|
|
||||||
|
# … und wieder zurücknehmen
|
||||||
|
curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||||
|
-d "{\"watchIds\": $IDS}" \
|
||||||
|
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
|
||||||
|
Markierung dieser Episoden im aktuellen Durchlauf.
|
||||||
|
|
||||||
|
### Suche
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/suche?q=nordlicht&typ=serie
|
||||||
|
```
|
||||||
|
|
||||||
|
Durchsucht TMDB. Die Treffer tragen `tmdbId` und `type` — direkt verwendbar für
|
||||||
|
`POST /api/v1/bibliothek`. Ohne hinterlegten TMDB-Schlüssel antwortet der
|
||||||
|
Endpunkt mit 503.
|
||||||
|
|
||||||
|
### Kalender
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30
|
||||||
|
```
|
||||||
|
|
||||||
|
Kommende Episoden der getrackten Serien. Ohne Zeitraum: die nächsten 30 Tage.
|
||||||
|
|
||||||
|
### Statistik
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/statistik?jahr=2026
|
||||||
|
```
|
||||||
|
|
||||||
|
Liefert `totals`, `monthly`, `providers`, `genres` und `yearReview` auf einmal.
|
||||||
|
|
||||||
|
### Universen
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/universen
|
||||||
|
POST /api/v1/universen
|
||||||
|
GET /api/v1/universen/{id}?reihenfolge={orderId}
|
||||||
|
POST /api/v1/universen/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
|
||||||
|
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
|
||||||
|
Erscheinungsreihenfolge.
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/export?format=json
|
||||||
|
GET /api/v1/export?format=csv
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON enthält zusätzlich alle Sichtungen und Universen.
|
||||||
|
|
||||||
|
## Beispiel: Serien mit offener Episode auflisten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
KEY="flm_…"
|
||||||
|
HOST="https://flimmer.example.com"
|
||||||
|
|
||||||
|
curl -s -H "Authorization: Bearer $KEY" \
|
||||||
|
"$HOST/api/v1/bibliothek?status=WATCHING&typ=SERIES" \
|
||||||
|
| jq -r '.items[] | "\(.title): \(.watched)/\(.total)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Grenzen
|
||||||
|
|
||||||
|
Es gibt bewusst keine Ratenbegrenzung: Flimmer läuft auf deinem eigenen Server,
|
||||||
|
und ein Limit gegen dich selbst hilft niemandem. Wer die Instanz öffentlich
|
||||||
|
erreichbar macht, setzt so etwas besser im Reverse Proxy.
|
||||||
|
|
||||||
|
Der Abgleich mit TMDB lässt sich über einen eigenen Endpunkt anstoßen — der
|
||||||
|
nimmt allerdings das Cron-Geheimnis, nicht einen API-Schlüssel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
|
||||||
|
https://flimmer.example.com/api/cron/sync
|
||||||
|
```
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flimmer",
|
"name": "flimmer",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -15,8 +15,9 @@
|
|||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"icons": "tsx scripts/generate-icons.ts",
|
"icons": "tsx scripts/generate-icons.ts",
|
||||||
"screenshots": "tsx scripts/screenshots.ts",
|
"screenshots": "node scripts/screenshots.mjs && node scripts/optimize-screenshots.mjs",
|
||||||
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts"
|
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts",
|
||||||
|
"tmdb:stub": "node --import tsx scripts/tmdb-stub.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Flimmer — Service Worker
|
||||||
|
*
|
||||||
|
* Bewusst handgeschrieben und knapp gehalten. Drei Strategien:
|
||||||
|
*
|
||||||
|
* Bilder und statische Dateien → zuerst aus dem Zwischenspeicher.
|
||||||
|
* Poster ändern sich unter einem TMDB-Pfad nie, das spart jede Anfrage.
|
||||||
|
* Seitenaufrufe → zuerst das Netz, sonst der Speicher,
|
||||||
|
* sonst die Offline-Seite. Ein Tracker, der veraltete Stände zeigt, wäre
|
||||||
|
* schlimmer als einer, der ehrlich sagt: gerade keine Verbindung.
|
||||||
|
* Alles Übrige (API, Aktionen) → gar kein Zwischenspeicher.
|
||||||
|
*
|
||||||
|
* Schreibende Anfragen laufen nie über den Service Worker.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VERSION = "flimmer-v1";
|
||||||
|
const SHELL_CACHE = `${VERSION}-shell`;
|
||||||
|
const IMAGE_CACHE = `${VERSION}-images`;
|
||||||
|
const PAGE_CACHE = `${VERSION}-pages`;
|
||||||
|
|
||||||
|
const OFFLINE_URL = "/offline";
|
||||||
|
|
||||||
|
const SHELL = [OFFLINE_URL, "/favicon.svg", "/icons/icon-192.png", "/manifest.webmanifest"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(SHELL_CACHE)
|
||||||
|
.then((cache) => cache.addAll(SHELL))
|
||||||
|
.then(() => self.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => !key.startsWith(VERSION)).map((key) => caches.delete(key))),
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Zwischenspeicher begrenzen, damit Poster ihn nicht endlos füllen. */
|
||||||
|
async function trim(cacheName, maxEntries) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
if (keys.length <= maxEntries) return;
|
||||||
|
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheFirst(request, cacheName, maxEntries) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
await cache.put(request, response.clone());
|
||||||
|
void trim(cacheName, maxEntries);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request) {
|
||||||
|
const cache = await caches.open(PAGE_CACHE);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
await cache.put(request, response.clone());
|
||||||
|
void trim(PAGE_CACHE, 40);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
|
||||||
|
const offline = await caches.match(OFFLINE_URL);
|
||||||
|
if (offline) return offline;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== "GET") return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Bilder aus dem eigenen Cache-Endpunkt und statische Next-Dateien
|
||||||
|
if (url.pathname.startsWith("/api/image")) {
|
||||||
|
event.respondWith(cacheFirst(request, IMAGE_CACHE, 400));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith("/_next/static") || url.pathname.startsWith("/icons/")) {
|
||||||
|
event.respondWith(cacheFirst(request, SHELL_CACHE, 200));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Übrige API-Aufrufe nie zwischenspeichern
|
||||||
|
if (url.pathname.startsWith("/api/")) return;
|
||||||
|
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Flimmer 0.6.0
|
||||||
|
|
||||||
|
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
|
||||||
|
— und eine bebilderte Anleitung.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Benachrichtigungen** über vier Wege: E-Mail (SMTP), ntfy, Gotify und ein
|
||||||
|
generischer Webhook, der zugleich Discord, Slack und Mattermost bedient.
|
||||||
|
Jeder Nutzer richtet seine Kanäle selbst ein, kann sie einzeln stummschalten
|
||||||
|
und mit einer Testnachricht ausprobieren. Dieselbe Meldung geht nie zweimal
|
||||||
|
raus — auch ein Fehlversuch wird protokolliert, sonst spammt der nächste Lauf
|
||||||
|
den Kanal voll, sobald er wieder erreichbar ist. Die E-Mail-Vorlage trägt
|
||||||
|
Logo und Farbwelt von Flimmer.
|
||||||
|
- **Täglicher Abgleich**: frischt laufende Serien bei TMDB auf, meldet neue
|
||||||
|
Episoden und neu verfügbare Watchlist-Titel, räumt Papierkorb und abgelaufene
|
||||||
|
Sitzungen. Eingebauter Zeitplan über `ENABLE_SCHEDULER` und `SYNC_HOUR`, von
|
||||||
|
außen anstoßbar über `/api/cron/sync` mit `CRON_SECRET`, und in der
|
||||||
|
Verwaltung per Knopfdruck. Ein einzelner Titel kann den Lauf nicht kippen.
|
||||||
|
- **Import** aus Trakt.tv (JSON, egal ob Watchlist, History, Ratings oder
|
||||||
|
Collection) und aus CSV mit Komma oder Semikolon. Der Import überschreibt
|
||||||
|
nichts: was schon in der Bibliothek liegt, bleibt unangetastet.
|
||||||
|
- **Export** der eigenen Daten als CSV oder als JSON mit allen Sichtungen und
|
||||||
|
Universen.
|
||||||
|
- **REST-API** unter `/api/v1` mit persönlichen API-Schlüsseln: Bibliothek
|
||||||
|
lesen und pflegen, Episoden abhaken und zurücknehmen, suchen, Kalender,
|
||||||
|
Statistik, Universen, Export. Beschreibung als OpenAPI 3.1 unter
|
||||||
|
`/api/v1/openapi.json`, dazu `docs/API.md` mit Beispielen.
|
||||||
|
- **Einstellungsseiten** für Profil, Region, Passwort, Benachrichtigungen,
|
||||||
|
API-Schlüssel sowie Import und Export.
|
||||||
|
- **Verwaltungsbereich**: Instanzname, Registrierung öffnen und schließen,
|
||||||
|
öffentliche Profile erlauben, Konten sperren und Rechte vergeben, Abgleich
|
||||||
|
anstoßen und der Zustand der Instanz auf einen Blick. Die letzte
|
||||||
|
Administration lässt sich nicht entmachten.
|
||||||
|
- **Öffentliche Profilseite** unter `/u/<adresse>` — Zahlen und zuletzt
|
||||||
|
Gesehenes, aber keine Notizen, keine Watchlist, keine E-Mail-Adresse.
|
||||||
|
Abschaltbar durch den Nutzer *und* instanzweit durch den Betreiber.
|
||||||
|
- **PWA-Feinschliff**: eigener Service Worker (Poster aus dem Zwischenspeicher,
|
||||||
|
Seiten zuerst aus dem Netz, Offline-Rückfall) und eine Offline-Seite.
|
||||||
|
- **`TMDB_BASE_URL`** für Instanzen hinter einem eigenen Proxy — und für den
|
||||||
|
mitgelieferten TMDB-Ersatz `scripts/tmdb-stub.mjs`, mit dem sich die Suche
|
||||||
|
ohne Schlüssel aus den Beispieldaten ausprobieren lässt.
|
||||||
|
- **Bebilderte Anleitung** `docs/ANLEITUNG.md` mit 18 Screenshots aus der
|
||||||
|
laufenden Instanz, aufgenommen von `npm run screenshots`.
|
||||||
|
- **9 weitere Tests** für CSV- und Trakt-Import, Export, das Aufräumen des
|
||||||
|
Papierkorbs und den Schutz vor Doppelversand — zusammen 49.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
import { readdir, unlink, stat } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verkleinert die aufgenommenen Screenshots für das Repository.
|
||||||
|
* Die Aufnahme läuft mit doppelter Pixeldichte (scharfe Schrift), abgelegt
|
||||||
|
* wird die halbe Kantenlänge als WebP — das spart rund 90 Prozent.
|
||||||
|
*/
|
||||||
|
const DIR = "docs/screenshots";
|
||||||
|
const files = (await readdir(DIR)).filter((name) => name.endsWith(".png"));
|
||||||
|
|
||||||
|
let before = 0;
|
||||||
|
let after = 0;
|
||||||
|
|
||||||
|
for (const name of files) {
|
||||||
|
const source = join(DIR, name);
|
||||||
|
before += (await stat(source)).size;
|
||||||
|
|
||||||
|
const image = sharp(source);
|
||||||
|
const meta = await image.metadata();
|
||||||
|
const target = join(DIR, name.replace(/\.png$/, ".webp"));
|
||||||
|
|
||||||
|
await image
|
||||||
|
.resize({ width: Math.round((meta.width ?? 1400) / 2) })
|
||||||
|
.webp({ quality: 82, effort: 5 })
|
||||||
|
.toFile(target);
|
||||||
|
|
||||||
|
after += (await stat(target)).size;
|
||||||
|
await unlink(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
|
||||||
|
console.log(`${files.length} Bilder: ${mb(before)} MB -> ${mb(after)} MB`);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { chromium } from "playwright";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const OUT = "docs/screenshots";
|
||||||
|
const BASE = "http://localhost:3011";
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const errs = [];
|
||||||
|
|
||||||
|
async function session(width, height, theme) {
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
viewport: { width, height },
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
colorScheme: theme === "light" ? "light" : "dark",
|
||||||
|
locale: "de-DE",
|
||||||
|
timezoneId: "Europe/Berlin",
|
||||||
|
});
|
||||||
|
await ctx.addInitScript((mode) => localStorage.setItem("flimmer-theme", mode), theme);
|
||||||
|
// Der Entwicklungs-Indikator von Next hat in einer Anleitung nichts verloren.
|
||||||
|
await ctx.addStyleTag?.({ content: "nextjs-portal { display: none !important; }" });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
|
||||||
|
page.on("pageerror", (e) => errs.push(String(e).slice(0, 160)));
|
||||||
|
return { ctx, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${BASE}/anmelden`);
|
||||||
|
await page.fill("#email", "demo@flimmer.local");
|
||||||
|
await page.fill("#password", "flimmer-demo-2026");
|
||||||
|
await Promise.all([page.waitForURL(`${BASE}/`), page.click("button[type=submit]")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shot(page, name, url, opts = {}) {
|
||||||
|
await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" });
|
||||||
|
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
|
||||||
|
await page.waitForTimeout(opts.wait ?? 900);
|
||||||
|
if (opts.before) await opts.before(page);
|
||||||
|
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: Boolean(opts.full) });
|
||||||
|
console.log(" ", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Anmeldung (ohne Sitzung) ---
|
||||||
|
{
|
||||||
|
const { ctx, page } = await session(1280, 800, "dark");
|
||||||
|
await shot(page, "01-anmeldung", "/anmelden");
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Angemeldet, dunkel ---
|
||||||
|
{
|
||||||
|
const { ctx, page } = await session(1400, 900, "dark");
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
await shot(page, "02-uebersicht", "/", { full: true });
|
||||||
|
await shot(page, "03-suche", "/suche?q=sternenpfad");
|
||||||
|
await shot(page, "05-bibliothek", "/bibliothek");
|
||||||
|
await shot(page, "09-kalender", "/kalender");
|
||||||
|
await shot(page, "10-statistik", "/statistik", { full: true });
|
||||||
|
await shot(page, "11-einstellungen", "/einstellungen");
|
||||||
|
await shot(page, "12-benachrichtigungen", "/einstellungen/benachrichtigungen");
|
||||||
|
await shot(page, "13-api", "/einstellungen/api");
|
||||||
|
await shot(page, "14-daten", "/einstellungen/daten");
|
||||||
|
await shot(page, "15-verwaltung", "/verwaltung", { full: true });
|
||||||
|
await shot(page, "16-papierkorb", "/papierkorb");
|
||||||
|
|
||||||
|
// Detailseite und Universum brauchen IDs aus der Bibliothek
|
||||||
|
await page.goto(`${BASE}/bibliothek?q=nordlicht`, { waitUntil: "networkidle" });
|
||||||
|
const titleHref = await page.locator("a[href^='/titel/']").first().getAttribute("href");
|
||||||
|
if (titleHref) await shot(page, "04-serie", titleHref, { full: true });
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/universen`, { waitUntil: "networkidle" });
|
||||||
|
await shot(page, "06-universen", "/universen");
|
||||||
|
const uniHref = await page.locator("a[href^='/universen/']").first().getAttribute("href");
|
||||||
|
if (uniHref) await shot(page, "07-universum", uniHref, { full: true });
|
||||||
|
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hell ---
|
||||||
|
{
|
||||||
|
const { ctx, page } = await session(1400, 900, "light");
|
||||||
|
await login(page);
|
||||||
|
await shot(page, "08-hell", "/bibliothek");
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mobil ---
|
||||||
|
{
|
||||||
|
const { ctx, page } = await session(430, 900, "dark");
|
||||||
|
await login(page);
|
||||||
|
await shot(page, "17-mobil-uebersicht", "/");
|
||||||
|
await shot(page, "18-mobil-serie", "/bibliothek");
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Fehler:", errs.length ? [...new Set(errs)].slice(0, 5) : "keine");
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Kleiner TMDB-Ersatz für Screenshots und manuelles Ausprobieren.
|
||||||
|
*
|
||||||
|
* node scripts/tmdb-stub.mjs [port]
|
||||||
|
*
|
||||||
|
* Beantwortet die Suchendpunkte aus dem lokalen Katalog (den das Seed-Skript
|
||||||
|
* angelegt hat) im Format der TMDB-API. Damit lässt sich die Suchseite auch
|
||||||
|
* ohne echten TMDB-Schlüssel zeigen — für Screenshots der Anleitung und um
|
||||||
|
* die Oberfläche auszuprobieren, bevor man einen Schlüssel besorgt.
|
||||||
|
*
|
||||||
|
* Verwendung zusammen mit:
|
||||||
|
* TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
|
||||||
|
*/
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "../src/generated/prisma/client.ts";
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.loadEnvFile(".env");
|
||||||
|
} catch {
|
||||||
|
// Werte kommen aus der Umgebung.
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number(process.argv[2] ?? 3099);
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function toResult(title, mediaType) {
|
||||||
|
const date = title.releaseDate?.toISOString().slice(0, 10) ?? null;
|
||||||
|
return {
|
||||||
|
id: title.tmdbId,
|
||||||
|
media_type: mediaType,
|
||||||
|
name: mediaType === "tv" ? title.title : undefined,
|
||||||
|
title: mediaType === "movie" ? title.title : undefined,
|
||||||
|
original_name: title.originalTitle ?? undefined,
|
||||||
|
original_title: title.originalTitle ?? undefined,
|
||||||
|
overview: title.overview ?? "",
|
||||||
|
poster_path: title.posterPath,
|
||||||
|
backdrop_path: title.backdropPath,
|
||||||
|
first_air_date: mediaType === "tv" ? date : undefined,
|
||||||
|
release_date: mediaType === "movie" ? date : undefined,
|
||||||
|
vote_average: title.voteAverage ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(query, type) {
|
||||||
|
const titles = await prisma.title.findMany({
|
||||||
|
where: {
|
||||||
|
tmdbId: { not: null },
|
||||||
|
...(type ? { type } : {}),
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: query, mode: "insensitive" } },
|
||||||
|
{ originalTitle: { contains: query, mode: "insensitive" } },
|
||||||
|
{ genres: { has: query } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { releaseDate: "desc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return titles.map((title) => toResult(title, title.type === "SERIES" ? "tv" : "movie"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
|
||||||
|
const query = url.searchParams.get("query") ?? "";
|
||||||
|
|
||||||
|
const send = (body, status = 200) => {
|
||||||
|
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
||||||
|
response.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (url.pathname.startsWith("/3/search/")) {
|
||||||
|
const kind = url.pathname.split("/").pop();
|
||||||
|
const type = kind === "tv" ? "SERIES" : kind === "movie" ? "MOVIE" : null;
|
||||||
|
const results = await search(query, type);
|
||||||
|
return send({ page: 1, results, total_pages: 1, total_results: results.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
return send({ status_message: `Kein Ersatz für ${url.pathname}` }, 404);
|
||||||
|
} catch (error) {
|
||||||
|
return send({ status_message: String(error) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, "127.0.0.1", () => {
|
||||||
|
console.log(`TMDB-Ersatz läuft auf http://127.0.0.1:${port}/3`);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
server.close();
|
||||||
|
void prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
checkPasswordStrength,
|
||||||
|
createApiKey,
|
||||||
|
destroyAllSessions,
|
||||||
|
getSessionUser,
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
} from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { setSetting } from "@/lib/settings";
|
||||||
|
import { sendTestNotification } from "@/lib/notify";
|
||||||
|
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
|
||||||
|
import { runSync } from "@/lib/sync";
|
||||||
|
|
||||||
|
/** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */
|
||||||
|
|
||||||
|
export type SettingsResult = { ok: boolean; message?: string; secret?: string };
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
fn: (userId: string) => Promise<{ message?: string; secret?: string } | void>,
|
||||||
|
): Promise<SettingsResult> {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = (await fn(user.id)) ?? {};
|
||||||
|
revalidatePath("/einstellungen", "layout");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
return { ok: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Einstellungs-Aktion fehlgeschlagen:", error);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireAdminUser() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
throw new Error("Dafür fehlen dir die Rechte.");
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Profil
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const profileSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(80),
|
||||||
|
region: z.string().trim().length(2).toUpperCase(),
|
||||||
|
publicProfile: z.boolean(),
|
||||||
|
profileSlug: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(/^[a-z0-9-]{3,40}$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 40 Zeichen.")
|
||||||
|
.nullable(),
|
||||||
|
notifyNewEpisode: z.boolean(),
|
||||||
|
notifyRelease: z.boolean(),
|
||||||
|
notifyAvailability: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateProfileAction(data: unknown) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
const parsed = profileSchema.parse(data);
|
||||||
|
|
||||||
|
if (parsed.publicProfile && !parsed.profileSlug) {
|
||||||
|
throw new Error("Für ein öffentliches Profil braucht es eine Adresse.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.profileSlug) {
|
||||||
|
const taken = await prisma.user.findFirst({
|
||||||
|
where: { profileSlug: parsed.profileSlug, id: { not: userId } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (taken) throw new Error("Diese Profiladresse ist schon vergeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id: userId }, data: parsed });
|
||||||
|
return { message: "Gespeichert" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordSchema = z.object({
|
||||||
|
current: z.string().min(1),
|
||||||
|
next: z.string().min(1),
|
||||||
|
repeat: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function changePasswordAction(data: unknown) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
const parsed = passwordSchema.parse(data);
|
||||||
|
|
||||||
|
if (parsed.next !== parsed.repeat) throw new Error("Die neuen Passwörter stimmen nicht überein.");
|
||||||
|
const weak = checkPasswordStrength(parsed.next);
|
||||||
|
if (weak) throw new Error(weak);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { passwordHash: true },
|
||||||
|
});
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
throw new Error("Dieses Konto meldet sich über SSO an und hat kein Passwort.");
|
||||||
|
}
|
||||||
|
if (!(await verifyPassword(parsed.current, user.passwordHash))) {
|
||||||
|
throw new Error("Das bisherige Passwort stimmt nicht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { passwordHash: await hashPassword(parsed.next) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nach einem Passwortwechsel fliegen alle anderen Geräte raus.
|
||||||
|
await destroyAllSessions(userId);
|
||||||
|
return { message: "Passwort geändert — bitte neu anmelden." };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API-Schlüssel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function createApiKeyAction(name: string) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
const clean = z.string().trim().min(1).max(60).parse(name);
|
||||||
|
const { plain } = await createApiKey(userId, clean);
|
||||||
|
// Der Klartext existiert nur hier — danach steht in der Datenbank nur der Hash.
|
||||||
|
return { message: "Schlüssel angelegt", secret: plain };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeApiKeyAction(keyId: string) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
await prisma.apiKey.updateMany({
|
||||||
|
where: { id: keyId, userId },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
return { message: "Schlüssel zurückgezogen" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benachrichtigungskanäle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const channelSchema = z.object({
|
||||||
|
kind: z.enum(["EMAIL", "NTFY", "GOTIFY", "WEBHOOK"]),
|
||||||
|
name: z.string().trim().min(1).max(60),
|
||||||
|
config: z.record(z.string(), z.string().max(500)).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function saveChannelAction(data: unknown, channelId?: string) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
const parsed = channelSchema.parse(data);
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
await prisma.notificationChannel.updateMany({
|
||||||
|
where: { id: channelId, userId },
|
||||||
|
data: { kind: parsed.kind, name: parsed.name, config: parsed.config },
|
||||||
|
});
|
||||||
|
return { message: "Kanal gespeichert" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.notificationChannel.create({
|
||||||
|
data: { userId, kind: parsed.kind, name: parsed.name, config: parsed.config },
|
||||||
|
});
|
||||||
|
return { message: "Kanal angelegt" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleChannelAction(channelId: string, enabled: boolean) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
await prisma.notificationChannel.updateMany({
|
||||||
|
where: { id: channelId, userId },
|
||||||
|
data: { enabled },
|
||||||
|
});
|
||||||
|
return { message: enabled ? "Kanal aktiv" : "Kanal ruht" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChannelAction(channelId: string) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
await prisma.notificationChannel.deleteMany({ where: { id: channelId, userId } });
|
||||||
|
return { message: "Kanal entfernt" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testChannelAction(channelId: string) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
await sendTestNotification(userId, channelId);
|
||||||
|
return { message: "Testnachricht verschickt" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ImportResult = { ok: boolean; message?: string; summary?: ImportSummary };
|
||||||
|
|
||||||
|
export async function importDataAction(
|
||||||
|
format: "csv" | "trakt",
|
||||||
|
content: string,
|
||||||
|
): Promise<ImportResult> {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||||
|
|
||||||
|
if (content.length > 8_000_000) {
|
||||||
|
return { ok: false, message: "Die Datei ist zu groß (mehr als 8 MB)." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = format === "csv" ? await importCsv(user.id, content) : await importTrakt(user.id, content);
|
||||||
|
revalidatePath("/bibliothek", "layout");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: `${summary.added} aufgenommen, ${summary.skipped} übersprungen`,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Import fehlgeschlagen:", error);
|
||||||
|
return { ok: false, message: error instanceof Error ? error.message : "Import fehlgeschlagen." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Verwaltung
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function setRegistrationAction(enabled: boolean) {
|
||||||
|
return run(async () => {
|
||||||
|
await requireAdminUser();
|
||||||
|
await setSetting("registrationEnabled", enabled);
|
||||||
|
return { message: enabled ? "Registrierung offen" : "Registrierung geschlossen" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setInstanceNameAction(name: string) {
|
||||||
|
return run(async () => {
|
||||||
|
await requireAdminUser();
|
||||||
|
await setSetting("instanceName", z.string().trim().min(1).max(60).parse(name));
|
||||||
|
return { message: "Name gespeichert" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPublicProfilesAction(enabled: boolean) {
|
||||||
|
return run(async () => {
|
||||||
|
await requireAdminUser();
|
||||||
|
await setSetting("publicProfilesEnabled", enabled);
|
||||||
|
return { message: enabled ? "Öffentliche Profile erlaubt" : "Öffentliche Profile abgeschaltet" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserActiveAction(targetId: string, active: boolean) {
|
||||||
|
return run(async (userId) => {
|
||||||
|
await requireAdminUser();
|
||||||
|
if (targetId === userId) throw new Error("Dich selbst kannst du nicht sperren.");
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id: targetId }, data: { isActive: active } });
|
||||||
|
if (!active) await destroyAllSessions(targetId);
|
||||||
|
|
||||||
|
revalidatePath("/verwaltung", "layout");
|
||||||
|
return { message: active ? "Konto freigegeben" : "Konto gesperrt" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserRoleAction(targetId: string, role: "ADMIN" | "USER") {
|
||||||
|
return run(async (userId) => {
|
||||||
|
await requireAdminUser();
|
||||||
|
if (targetId === userId) throw new Error("Deine eigene Rolle kannst du nicht ändern.");
|
||||||
|
|
||||||
|
if (role === "USER") {
|
||||||
|
const admins = await prisma.user.count({ where: { role: "ADMIN", isActive: true } });
|
||||||
|
if (admins <= 1) throw new Error("Es muss mindestens eine Administration geben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id: targetId }, data: { role } });
|
||||||
|
revalidatePath("/verwaltung", "layout");
|
||||||
|
return { message: role === "ADMIN" ? "Zur Administration ernannt" : "Rechte entzogen" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSyncAction() {
|
||||||
|
return run(async () => {
|
||||||
|
await requireAdminUser();
|
||||||
|
const stats = await runSync({ limit: 40 });
|
||||||
|
revalidatePath("/verwaltung", "layout");
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
stats.errors.length === 0
|
||||||
|
? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen`
|
||||||
|
: `Mit ${stats.errors.length} Fehlern beendet: ${stats.errors[0]}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { getSessionUser } from "@/lib/auth";
|
||||||
|
import { runSync } from "@/lib/sync";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abgleich von außen anstoßen.
|
||||||
|
*
|
||||||
|
* curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
|
||||||
|
* https://flimmer.example/api/cron/sync
|
||||||
|
*
|
||||||
|
* Zugelassen ist entweder das Cron-Geheimnis oder eine angemeldete
|
||||||
|
* Administration — Letzteres für den Knopf im Verwaltungsbereich.
|
||||||
|
*/
|
||||||
|
async function authorize(request: Request): Promise<boolean> {
|
||||||
|
const header = request.headers.get("authorization");
|
||||||
|
if (env.scheduler.cronSecret && header === `Bearer ${env.scheduler.cronSecret}`) return true;
|
||||||
|
|
||||||
|
const user = await getSessionUser();
|
||||||
|
return user?.role === "ADMIN";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!(await authorize(request))) {
|
||||||
|
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await runSync();
|
||||||
|
return NextResponse.json({ ok: stats.errors.length === 0, ...stats });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET erlaubt einfache Uptime-Cron-Dienste, die kein POST können. */
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
return POST(request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { apiError, readJson, withApi } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
getNextEpisode,
|
||||||
|
getProgress,
|
||||||
|
moveToTrash,
|
||||||
|
setStatus,
|
||||||
|
updateEntryDetails,
|
||||||
|
} from "@/lib/library";
|
||||||
|
|
||||||
|
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const;
|
||||||
|
|
||||||
|
/** GET /api/v1/bibliothek/{id} — ein Eintrag mit Fortschritt und nächster Episode. */
|
||||||
|
export const GET = withApi(async (_request, user, context) => {
|
||||||
|
const { entryId } = await context.params;
|
||||||
|
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||||
|
|
||||||
|
const entry = await prisma.libraryEntry.findFirst({
|
||||||
|
where: { id: entryId, userId: user.id },
|
||||||
|
include: {
|
||||||
|
title: true,
|
||||||
|
tags: { include: { tag: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!entry) return apiError("Diesen Eintrag gibt es nicht.", 404);
|
||||||
|
|
||||||
|
const [progress, next] = await Promise.all([
|
||||||
|
getProgress(user.id, entry.id),
|
||||||
|
getNextEpisode(user.id, entry.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
status: entry.status,
|
||||||
|
rating: entry.rating,
|
||||||
|
notes: entry.notes,
|
||||||
|
whereToWatch: entry.whereToWatch,
|
||||||
|
favorite: entry.favorite,
|
||||||
|
currentRun: entry.currentRun,
|
||||||
|
tags: entry.tags.map((link) => link.tag.name),
|
||||||
|
title: {
|
||||||
|
id: entry.title.id,
|
||||||
|
title: entry.title.title,
|
||||||
|
type: entry.title.type,
|
||||||
|
tmdbId: entry.title.tmdbId,
|
||||||
|
year: entry.title.releaseDate?.getFullYear() ?? null,
|
||||||
|
genres: entry.title.genres,
|
||||||
|
},
|
||||||
|
progress,
|
||||||
|
nextEpisode: next,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchSchema = z.object({
|
||||||
|
status: z.enum(STATUSES).optional(),
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */
|
||||||
|
export const PATCH = withApi(async (request, user, context) => {
|
||||||
|
const { entryId } = await context.params;
|
||||||
|
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||||
|
|
||||||
|
const { status, ...details } = await readJson(request, patchSchema);
|
||||||
|
|
||||||
|
if (status) await setStatus(user.id, entryId, status);
|
||||||
|
if (Object.keys(details).length > 0) {
|
||||||
|
await updateEntryDetails(user.id, entryId, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/v1/bibliothek/{id} — in den Papierkorb legen. */
|
||||||
|
export const DELETE = withApi(async (_request, user, context) => {
|
||||||
|
const { entryId } = await context.params;
|
||||||
|
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||||
|
|
||||||
|
await moveToTrash(user.id, entryId);
|
||||||
|
return { ok: true, message: "In den Papierkorb gelegt." };
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { apiError, readJson, withApi } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
markEpisodesWatched,
|
||||||
|
markMovieWatched,
|
||||||
|
markSeasonWatched,
|
||||||
|
markUpTo,
|
||||||
|
undoWatches,
|
||||||
|
unmarkEpisodes,
|
||||||
|
watchNextEpisode,
|
||||||
|
} from "@/lib/library";
|
||||||
|
|
||||||
|
/** GET — die Sichtungen eines Eintrags, jüngste zuerst. */
|
||||||
|
export const GET = withApi(async (_request, user, context) => {
|
||||||
|
const { entryId } = await context.params;
|
||||||
|
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||||
|
|
||||||
|
const watches = await prisma.watch.findMany({
|
||||||
|
where: { userId: user.id, entryId },
|
||||||
|
orderBy: { watchedAt: "desc" },
|
||||||
|
take: 500,
|
||||||
|
include: { episode: { select: { seasonNumber: true, episodeNumber: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: watches.length,
|
||||||
|
items: watches.map((watch) => ({
|
||||||
|
id: watch.id,
|
||||||
|
run: watch.run,
|
||||||
|
watchedAt: watch.watchedAt,
|
||||||
|
minutes: watch.minutes,
|
||||||
|
episode: watch.episode,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const postSchema = z.union([
|
||||||
|
z.object({ action: z.literal("next") }),
|
||||||
|
z.object({ action: z.literal("movie") }),
|
||||||
|
z.object({
|
||||||
|
action: z.literal("episodes"),
|
||||||
|
episodeIds: z.array(z.string().min(1)).min(1).max(500),
|
||||||
|
}),
|
||||||
|
z.object({ action: z.literal("season"), seasonId: z.string().min(1) }),
|
||||||
|
z.object({ action: z.literal("upTo"), episodeId: z.string().min(1) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST — etwas als gesehen markieren.
|
||||||
|
*
|
||||||
|
* Die Antwort enthält immer `watchIds`. Damit lässt sich die Aktion über
|
||||||
|
* DELETE gezielt zurücknehmen, ohne raten zu müssen, was dazugehörte.
|
||||||
|
*/
|
||||||
|
export const POST = withApi(async (request, user, context) => {
|
||||||
|
const { entryId } = await context.params;
|
||||||
|
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||||
|
|
||||||
|
const body = await readJson(request, postSchema);
|
||||||
|
|
||||||
|
switch (body.action) {
|
||||||
|
case "next":
|
||||||
|
return watchNextEpisode(user.id, entryId);
|
||||||
|
case "movie":
|
||||||
|
return markMovieWatched(user.id, entryId);
|
||||||
|
case "episodes":
|
||||||
|
return markEpisodesWatched(user.id, entryId, body.episodeIds);
|
||||||
|
case "season":
|
||||||
|
return markSeasonWatched(user.id, entryId, body.seasonId);
|
||||||
|
case "upTo":
|
||||||
|
return markUpTo(user.id, entryId, body.episodeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSchema = z.union([
|
||||||
|
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||||
|
z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */
|
||||||
|
export const DELETE = withApi(async (request, user, context) => {
|
||||||
|
const { entryId } = await context.params;
|
||||||
|
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||||
|
|
||||||
|
const body = await readJson(request, deleteSchema);
|
||||||
|
|
||||||
|
if ("watchIds" in body) {
|
||||||
|
await undoWatches(user.id, entryId, body.watchIds);
|
||||||
|
} else {
|
||||||
|
await unmarkEpisodes(user.id, entryId, body.episodeIds);
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { readJson, readPaging, withApi } from "@/lib/api";
|
||||||
|
import { getLibrary } from "@/lib/queries";
|
||||||
|
import { addToLibrary } from "@/lib/library";
|
||||||
|
import { ensureTitle } from "@/lib/catalog";
|
||||||
|
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const;
|
||||||
|
|
||||||
|
/** GET /api/v1/bibliothek — Bibliothek lesen, mit denselben Filtern wie im UI. */
|
||||||
|
export const GET = withApi(async (request, user) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const { limit, offset } = readPaging(request);
|
||||||
|
|
||||||
|
const status = url.searchParams.get("status");
|
||||||
|
const type = url.searchParams.get("typ");
|
||||||
|
|
||||||
|
const items = await getLibrary(user.id, {
|
||||||
|
query: url.searchParams.get("q") ?? undefined,
|
||||||
|
status: STATUSES.includes(status as EntryStatus) ? (status as EntryStatus) : undefined,
|
||||||
|
type: type === "SERIES" || type === "MOVIE" ? (type as TitleType) : undefined,
|
||||||
|
genre: url.searchParams.get("genre") ?? undefined,
|
||||||
|
tag: url.searchParams.get("tag") ?? undefined,
|
||||||
|
provider: url.searchParams.get("dienst") ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: items.length,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
items: items.slice(offset, offset + limit),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const addSchema = z
|
||||||
|
.object({
|
||||||
|
titleId: z.string().min(1).optional(),
|
||||||
|
tmdbId: z.number().int().positive().optional(),
|
||||||
|
type: z.enum(["SERIES", "MOVIE"]).optional(),
|
||||||
|
status: z.enum(STATUSES).default("WATCHLIST"),
|
||||||
|
})
|
||||||
|
.refine((value) => Boolean(value.titleId) || Boolean(value.tmdbId && value.type), {
|
||||||
|
message: "Entweder titleId oder tmdbId zusammen mit type angeben.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/v1/bibliothek — Titel aufnehmen, per interner ID oder TMDB-ID. */
|
||||||
|
export const POST = withApi(async (request, user) => {
|
||||||
|
const body = await readJson(request, addSchema);
|
||||||
|
|
||||||
|
let titleId = body.titleId;
|
||||||
|
if (!titleId && body.tmdbId && body.type) {
|
||||||
|
const title = await ensureTitle(body.type, body.tmdbId, { region: user.region });
|
||||||
|
titleId = title.id;
|
||||||
|
}
|
||||||
|
if (!titleId) {
|
||||||
|
throw new z.ZodError([
|
||||||
|
{ code: "custom", path: ["titleId"], message: "Kein Titel angegeben." },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await addToLibrary(user.id, titleId, body.status);
|
||||||
|
return { id: entry.id, titleId: entry.titleId, status: entry.status };
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withApi } from "@/lib/api";
|
||||||
|
import { exportCsv, exportJson } from "@/lib/transfer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/export?format=json|csv
|
||||||
|
*
|
||||||
|
* Vollständiger Export der eigenen Daten. JSON enthält zusätzlich Sichtungen
|
||||||
|
* und Universen, CSV ist die schlanke Tabellenform für Tabellenkalkulationen.
|
||||||
|
*/
|
||||||
|
export const GET = withApi(async (request, user) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const format = url.searchParams.get("format") === "csv" ? "csv" : "json";
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (format === "csv") {
|
||||||
|
const csv = await exportCsv(user.id);
|
||||||
|
return new NextResponse(csv, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/csv; charset=utf-8",
|
||||||
|
"content-disposition": `attachment; filename="flimmer-${stamp}.csv"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await exportJson(user.id);
|
||||||
|
return new NextResponse(JSON.stringify(data, null, 2), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"content-disposition": `attachment; filename="flimmer-${stamp}.json"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { withApi } from "@/lib/api";
|
||||||
|
import { getUpcomingEpisodes } from "@/lib/stats";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30
|
||||||
|
*
|
||||||
|
* Kommende Episoden der getrackten Serien. Ohne Angabe: die nächsten 30 Tage.
|
||||||
|
*/
|
||||||
|
export const GET = withApi(async (request, user) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const parse = (value: string | null, fallback: Date) => {
|
||||||
|
if (!value) return fallback;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? fallback : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const from = parse(url.searchParams.get("von"), new Date());
|
||||||
|
const to = parse(url.searchParams.get("bis"), new Date(Date.now() + 30 * 86_400_000));
|
||||||
|
|
||||||
|
const episodes = await getUpcomingEpisodes(user.id, from, to);
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString(),
|
||||||
|
total: episodes.length,
|
||||||
|
items: episodes,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { withApi } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/** GET /api/v1/me — wer bin ich, und wie groß ist meine Bibliothek? */
|
||||||
|
export const GET = withApi(async (_request, user) => {
|
||||||
|
const [entries, watches] = await Promise.all([
|
||||||
|
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||||
|
prisma.watch.count({ where: { userId: user.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
region: user.region,
|
||||||
|
library: { entries, watches },
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import pkg from "../../../../../package.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maschinenlesbare Beschreibung der REST-API.
|
||||||
|
*
|
||||||
|
* Handgeschrieben statt aus Zod erzeugt: die Schnittstelle ist überschaubar,
|
||||||
|
* und ein Generator brächte eine weitere Abhängigkeit samt Versionsrhythmus
|
||||||
|
* für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
const document = {
|
||||||
|
openapi: "3.1.0",
|
||||||
|
info: {
|
||||||
|
title: "Flimmer API",
|
||||||
|
version: pkg.version,
|
||||||
|
description:
|
||||||
|
"REST-Schnittstelle einer Flimmer-Instanz. Anmeldung über einen persönlichen " +
|
||||||
|
"API-Schlüssel (Einstellungen → API-Schlüssel) als Bearer-Token. Alle Antworten " +
|
||||||
|
"sind JSON; Zeitangaben in ISO 8601, Laufzeiten in Minuten.",
|
||||||
|
license: { name: "MIT" },
|
||||||
|
},
|
||||||
|
servers: [{ url: `${env.appUrl}/api/v1` }],
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: { type: "http", scheme: "bearer", description: "Persönlicher API-Schlüssel" },
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
Fehler: {
|
||||||
|
type: "object",
|
||||||
|
properties: { error: { type: "string" }, details: {} },
|
||||||
|
required: ["error"],
|
||||||
|
},
|
||||||
|
Sichtungsergebnis: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
watchIds: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Kennungen der neu angelegten Sichtungen — für DELETE zum Zurücknehmen.",
|
||||||
|
},
|
||||||
|
status: { type: "string" },
|
||||||
|
label: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paths: {
|
||||||
|
"/me": {
|
||||||
|
get: {
|
||||||
|
summary: "Angemeldetes Konto",
|
||||||
|
responses: { "200": { description: "Konto samt Umfang der Bibliothek" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/bibliothek": {
|
||||||
|
get: {
|
||||||
|
summary: "Bibliothek lesen",
|
||||||
|
parameters: [
|
||||||
|
{ name: "q", in: "query", schema: { type: "string" }, description: "Volltextsuche" },
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
in: "query",
|
||||||
|
schema: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: "typ", in: "query", schema: { type: "string", enum: ["SERIES", "MOVIE"] } },
|
||||||
|
{ name: "genre", in: "query", schema: { type: "string" } },
|
||||||
|
{ name: "tag", in: "query", schema: { type: "string" } },
|
||||||
|
{ name: "dienst", in: "query", schema: { type: "string" } },
|
||||||
|
{ name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } },
|
||||||
|
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } },
|
||||||
|
],
|
||||||
|
responses: { "200": { description: "Einträge mit Fortschritt" } },
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
summary: "Titel aufnehmen",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
titleId: { type: "string", description: "Interne Kennung" },
|
||||||
|
tmdbId: { type: "integer", description: "Alternativ: TMDB-Kennung" },
|
||||||
|
type: { type: "string", enum: ["SERIES", "MOVIE"] },
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
|
||||||
|
default: "WATCHLIST",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { "200": { description: "Angelegter oder vorhandener Eintrag" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/bibliothek/{entryId}": {
|
||||||
|
parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }],
|
||||||
|
get: { summary: "Eintrag mit Fortschritt", responses: { "200": { description: "Eintrag" } } },
|
||||||
|
patch: {
|
||||||
|
summary: "Status, Bewertung, Notiz ändern",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
|
||||||
|
},
|
||||||
|
rating: { type: "integer", minimum: 1, maximum: 10, nullable: true },
|
||||||
|
notes: { type: "string", nullable: true },
|
||||||
|
whereToWatch: { type: "string", nullable: true },
|
||||||
|
favorite: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { "200": { description: "Gespeichert" } },
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
summary: "In den Papierkorb legen",
|
||||||
|
responses: { "200": { description: "Verschoben" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/bibliothek/{entryId}/sichtungen": {
|
||||||
|
parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }],
|
||||||
|
get: { summary: "Sichtungen auflisten", responses: { "200": { description: "Sichtungen" } } },
|
||||||
|
post: {
|
||||||
|
summary: "Als gesehen markieren",
|
||||||
|
description:
|
||||||
|
"action=next hakt die nächste offene Episode ab, movie einen Film, " +
|
||||||
|
"episodes eine Auswahl, season eine ganze Staffel, upTo alles bis zur genannten Episode.",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { action: { const: "next" } },
|
||||||
|
required: ["action"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { action: { const: "movie" } },
|
||||||
|
required: ["action"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
action: { const: "episodes" },
|
||||||
|
episodeIds: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
required: ["action", "episodeIds"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { action: { const: "season" }, seasonId: { type: "string" } },
|
||||||
|
required: ["action", "seasonId"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { action: { const: "upTo" }, episodeId: { type: "string" } },
|
||||||
|
required: ["action", "episodeId"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "Angelegte Sichtungen",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/Sichtungsergebnis" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
summary: "Sichtungen zurücknehmen",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
oneOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { watchIds: { type: "array", items: { type: "string" } } },
|
||||||
|
required: ["watchIds"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { episodeIds: { type: "array", items: { type: "string" } } },
|
||||||
|
required: ["episodeIds"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { "200": { description: "Zurückgenommen" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/suche": {
|
||||||
|
get: {
|
||||||
|
summary: "TMDB durchsuchen",
|
||||||
|
parameters: [
|
||||||
|
{ name: "q", in: "query", required: true, schema: { type: "string", minLength: 2 } },
|
||||||
|
{ name: "typ", in: "query", schema: { type: "string", enum: ["serie", "film"] } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
"200": { description: "Treffer mit TMDB-Kennung" },
|
||||||
|
"503": { description: "Kein TMDB-Schlüssel hinterlegt" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/kalender": {
|
||||||
|
get: {
|
||||||
|
summary: "Kommende Episoden",
|
||||||
|
parameters: [
|
||||||
|
{ name: "von", in: "query", schema: { type: "string", format: "date" } },
|
||||||
|
{ name: "bis", in: "query", schema: { type: "string", format: "date" } },
|
||||||
|
],
|
||||||
|
responses: { "200": { description: "Termine im Zeitraum" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/statistik": {
|
||||||
|
get: {
|
||||||
|
summary: "Kennzahlen und Verteilungen",
|
||||||
|
parameters: [{ name: "jahr", in: "query", schema: { type: "integer" } }],
|
||||||
|
responses: { "200": { description: "Statistik" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/universen": {
|
||||||
|
get: { summary: "Universen auflisten", responses: { "200": { description: "Universen" } } },
|
||||||
|
post: {
|
||||||
|
summary: "Universum anlegen",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" }, description: { type: "string" } },
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { "200": { description: "Angelegt" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/universen/{id}": {
|
||||||
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||||
|
get: {
|
||||||
|
summary: "Universum mit Reihenfolge und Fortschritt",
|
||||||
|
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
|
||||||
|
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
summary: "Titel ins Universum aufnehmen",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { titleId: { type: "string" } },
|
||||||
|
required: ["titleId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: { "200": { description: "Aufgenommen" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/export": {
|
||||||
|
get: {
|
||||||
|
summary: "Eigene Daten exportieren",
|
||||||
|
parameters: [
|
||||||
|
{ name: "format", in: "query", schema: { type: "string", enum: ["json", "csv"] } },
|
||||||
|
],
|
||||||
|
responses: { "200": { description: "Datei" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(document, {
|
||||||
|
headers: { "cache-control": "public, max-age=300" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { withApi } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
getGenreDistribution,
|
||||||
|
getMonthlyActivity,
|
||||||
|
getProviderDistribution,
|
||||||
|
getTotals,
|
||||||
|
getYearReview,
|
||||||
|
} from "@/lib/stats";
|
||||||
|
|
||||||
|
/** GET /api/v1/statistik?jahr=2026 — alle Kennzahlen auf einmal. */
|
||||||
|
export const GET = withApi(async (request, user) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const parsed = Number.parseInt(url.searchParams.get("jahr") ?? "", 10);
|
||||||
|
const year = Number.isFinite(parsed) ? parsed : new Date().getFullYear();
|
||||||
|
|
||||||
|
const [totals, monthly, providers, genres, review] = await Promise.all([
|
||||||
|
getTotals(user.id),
|
||||||
|
getMonthlyActivity(user.id, 12),
|
||||||
|
getProviderDistribution(user.id),
|
||||||
|
getGenreDistribution(user.id),
|
||||||
|
getYearReview(user.id, year),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { totals, monthly, providers, genres, yearReview: review };
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { apiError, withApi } from "@/lib/api";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { searchMovies, searchMulti, searchSeries, TmdbNotConfiguredError } from "@/lib/tmdb";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/suche?q=…&typ=serie|film
|
||||||
|
*
|
||||||
|
* Durchsucht TMDB. Die Treffer tragen die TMDB-ID — damit lässt sich der
|
||||||
|
* Titel über POST /api/v1/bibliothek direkt aufnehmen.
|
||||||
|
*/
|
||||||
|
export const GET = withApi(async (request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const query = (url.searchParams.get("q") ?? "").trim();
|
||||||
|
const type = url.searchParams.get("typ");
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
return apiError("Der Suchbegriff braucht mindestens zwei Zeichen.", 400);
|
||||||
|
}
|
||||||
|
if (!env.tmdb.isConfigured) {
|
||||||
|
return apiError("Auf dieser Instanz ist kein TMDB-Schlüssel hinterlegt.", 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data =
|
||||||
|
type === "serie"
|
||||||
|
? await searchSeries(query)
|
||||||
|
: type === "film"
|
||||||
|
? await searchMovies(query)
|
||||||
|
: await searchMulti(query);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
total: data.total_results,
|
||||||
|
items: data.results.map((item) => {
|
||||||
|
const isSeries =
|
||||||
|
item.media_type === "tv" || (type === "serie" && item.media_type === undefined);
|
||||||
|
const date = isSeries ? item.first_air_date : item.release_date;
|
||||||
|
return {
|
||||||
|
tmdbId: item.id,
|
||||||
|
type: isSeries ? "SERIES" : "MOVIE",
|
||||||
|
title: item.name ?? item.title ?? "Ohne Titel",
|
||||||
|
overview: item.overview ?? null,
|
||||||
|
posterPath: item.poster_path ?? null,
|
||||||
|
year: date ? Number.parseInt(date.slice(0, 4), 10) : null,
|
||||||
|
voteAverage: item.vote_average ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TmdbNotConfiguredError) return apiError(error.message, 503);
|
||||||
|
return apiError(error instanceof Error ? error.message : "Die Suche ist fehlgeschlagen.", 502);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { apiError, readJson, withApi } from "@/lib/api";
|
||||||
|
import { addTitle, getUniverse } from "@/lib/universes";
|
||||||
|
|
||||||
|
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
|
||||||
|
export const GET = withApi(async (request, user, context) => {
|
||||||
|
const { id } = await context.params;
|
||||||
|
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const universe = await getUniverse(user.id, id, url.searchParams.get("reihenfolge") ?? undefined);
|
||||||
|
if (!universe) return apiError("Dieses Universum gibt es nicht.", 404);
|
||||||
|
|
||||||
|
return universe;
|
||||||
|
});
|
||||||
|
|
||||||
|
const addSchema = z.object({ titleId: z.string().min(1) });
|
||||||
|
|
||||||
|
/** POST /api/v1/universen/{id} — Titel aufnehmen. */
|
||||||
|
export const POST = withApi(async (request, user, context) => {
|
||||||
|
const { id } = await context.params;
|
||||||
|
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
|
||||||
|
|
||||||
|
const body = await readJson(request, addSchema);
|
||||||
|
await addTitle(user.id, id, body.titleId);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { readJson, withApi } from "@/lib/api";
|
||||||
|
import { createUniverse, listUniverses } from "@/lib/universes";
|
||||||
|
|
||||||
|
/** GET /api/v1/universen — eigene und geteilte Universen. */
|
||||||
|
export const GET = withApi(async (_request, user) => {
|
||||||
|
const universes = await listUniverses(user.id);
|
||||||
|
return { total: universes.length, items: universes };
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
description: z.string().max(2000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/v1/universen — neues Universum anlegen. */
|
||||||
|
export const POST = withApi(async (request, user) => {
|
||||||
|
const body = await readJson(request, createSchema);
|
||||||
|
const universe = await createUniverse(user.id, body.name, body.description);
|
||||||
|
return { id: universe.id, name: universe.name, slug: universe.slug };
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { ApiKeys } from "@/components/settings/Forms";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "API-Schlüssel" };
|
||||||
|
|
||||||
|
export default async function ApiPage() {
|
||||||
|
const user = await requireUser("/einstellungen/api");
|
||||||
|
|
||||||
|
const keys = await prisma.apiKey.findMany({
|
||||||
|
where: { userId: user.id, revokedAt: null },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const example = [
|
||||||
|
'curl -H "Authorization: Bearer <dein-schlüssel>" \\',
|
||||||
|
` ${env.appUrl}/api/v1/bibliothek?status=WATCHING`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="text-lg font-semibold">API-Schlüssel</h2>
|
||||||
|
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Für Skripte und eigene Anwendungen. Ein Schlüssel hat dieselben Rechte wie dein Konto —
|
||||||
|
gib ihn nicht weiter und zieh ihn zurück, sobald du ihn nicht mehr brauchst.
|
||||||
|
</p>
|
||||||
|
<ApiKeys keys={keys} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">So wird er benutzt</h2>
|
||||||
|
<pre className="overflow-x-auto rounded-xl bg-[var(--fl-surface-2)] p-3 text-xs leading-relaxed">
|
||||||
|
{example}
|
||||||
|
</pre>
|
||||||
|
<p className="mt-3 text-sm text-[var(--fl-muted)]">
|
||||||
|
Die vollständige Beschreibung steht in{" "}
|
||||||
|
<a
|
||||||
|
href="/api/v1/openapi.json"
|
||||||
|
className="text-[var(--fl-accent)] underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
openapi.json
|
||||||
|
</a>{" "}
|
||||||
|
und in <code>docs/API.md</code> im Repository.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { ChannelList } from "@/components/settings/Forms";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Benachrichtigungen" };
|
||||||
|
|
||||||
|
export default async function NotificationsPage() {
|
||||||
|
const user = await requireUser("/einstellungen/benachrichtigungen");
|
||||||
|
|
||||||
|
const [channels, recent] = await Promise.all([
|
||||||
|
prisma.notificationChannel.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.notificationLog.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { sentAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Kanäle</h2>
|
||||||
|
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Wohin Flimmer dir Bescheid gibt. Was gemeldet wird, stellst du im Profil ein.
|
||||||
|
</p>
|
||||||
|
<ChannelList
|
||||||
|
channels={channels.map((channel) => ({
|
||||||
|
id: channel.id,
|
||||||
|
kind: channel.kind,
|
||||||
|
name: channel.name,
|
||||||
|
config: (channel.config ?? {}) as Record<string, string>,
|
||||||
|
enabled: channel.enabled,
|
||||||
|
}))}
|
||||||
|
smtpReady={env.smtp.isConfigured}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Zuletzt verschickt</h2>
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--fl-faint)]">Noch nichts verschickt.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--fl-border)] text-sm">
|
||||||
|
{recent.map((entry) => (
|
||||||
|
<li key={entry.id} className="py-2.5">
|
||||||
|
<p className="font-medium">{entry.subject}</p>
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
{entry.sentAt.toLocaleString("de-DE")}
|
||||||
|
{entry.ok ? "" : ` · fehlgeschlagen: ${entry.error ?? "unbekannt"}`}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { ImportForm } from "@/components/settings/Forms";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Import und Export" };
|
||||||
|
|
||||||
|
export default async function DataPage() {
|
||||||
|
const user = await requireUser("/einstellungen/daten");
|
||||||
|
|
||||||
|
const [entries, watches] = await Promise.all([
|
||||||
|
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||||
|
prisma.watch.count({ where: { userId: user.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Import</h2>
|
||||||
|
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Bringt Titel aus einem anderen Werkzeug mit.
|
||||||
|
</p>
|
||||||
|
<ImportForm />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="text-lg font-semibold">Export</h2>
|
||||||
|
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Deine Daten gehören dir: {entries} Einträge und {watches} Sichtungen zum Mitnehmen.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<a href="/api/v1/export?format=json" className="fl-btn fl-btn-primary" download>
|
||||||
|
Als JSON
|
||||||
|
</a>
|
||||||
|
<a href="/api/v1/export?format=csv" className="fl-btn fl-btn-ghost" download>
|
||||||
|
Als CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-[var(--fl-faint)]">
|
||||||
|
JSON enthält zusätzlich alle Sichtungen und Universen, CSV ist die schlanke Tabellenform
|
||||||
|
für Tabellenkalkulationen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { SettingsNav } from "@/components/settings/SettingsNav";
|
||||||
|
|
||||||
|
/** Rahmen der Einstellungen: Seitennavigation links, Inhalt rechts. */
|
||||||
|
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = await requireUser("/einstellungen");
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell user={user} instanceName={settings.instanceName}>
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||||
|
<SettingsNav isAdmin={user.role === "ADMIN"} />
|
||||||
|
<div className="min-w-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { PasswordForm, ProfileForm } from "@/components/settings/Forms";
|
||||||
|
import pkg from "../../../package.json";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Einstellungen" };
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const session = await requireUser("/einstellungen");
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: session.id },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
region: true,
|
||||||
|
publicProfile: true,
|
||||||
|
profileSlug: true,
|
||||||
|
notifyNewEpisode: true,
|
||||||
|
notifyRelease: true,
|
||||||
|
notifyAvailability: true,
|
||||||
|
passwordHash: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { passwordHash, ...profile } = user;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Profil</h2>
|
||||||
|
<ProfileForm user={profile} publicProfilesEnabled={settings.publicProfilesEnabled} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{passwordHash && (
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Passwort</h2>
|
||||||
|
<PasswordForm />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
|||||||
import { Outfit } from "next/font/google";
|
import { Outfit } from "next/font/google";
|
||||||
import { BrandDefs } from "@/components/brand/FlimmerLogo";
|
import { BrandDefs } from "@/components/brand/FlimmerLogo";
|
||||||
import { ToastProvider } from "@/components/Toasts";
|
import { ToastProvider } from "@/components/Toasts";
|
||||||
|
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
Zum Inhalt springen
|
Zum Inhalt springen
|
||||||
</a>
|
</a>
|
||||||
<ToastProvider>{children}</ToastProvider>
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Keine Verbindung" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückfallseite des Service Workers.
|
||||||
|
*
|
||||||
|
* Bewusst ohne Datenzugriff: sie muss auch dann funktionieren, wenn der
|
||||||
|
* Server nicht erreichbar ist, und wird deshalb statisch vorgerendert.
|
||||||
|
*/
|
||||||
|
export default function OfflinePage() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
id="inhalt"
|
||||||
|
className="flex min-h-dvh flex-col items-center justify-center px-6 text-center"
|
||||||
|
>
|
||||||
|
<FlimmerMark className="h-16 w-16" />
|
||||||
|
<h1 className="mt-6 text-2xl font-semibold tracking-tight">Gerade keine Verbindung</h1>
|
||||||
|
<p className="mt-2 max-w-sm text-[var(--fl-muted)]">
|
||||||
|
Flimmer erreicht deinen Server nicht. Bereits geladene Seiten und Poster bleiben verfügbar —
|
||||||
|
alles Weitere kommt zurück, sobald die Verbindung wieder steht.
|
||||||
|
</p>
|
||||||
|
<Link href="/" className="fl-btn fl-btn-primary mt-6">
|
||||||
|
Noch einmal versuchen
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
|
||||||
|
import { formatMinutes, formatRelative } from "@/lib/labels";
|
||||||
|
import { FlimmerLogo } from "@/components/brand/FlimmerLogo";
|
||||||
|
import { Poster } from "@/components/Poster";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
|
||||||
|
type Params = Promise<{ slug: string }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffentliche Profilseite.
|
||||||
|
*
|
||||||
|
* Zeigt Zahlen und zuletzt Gesehenes — **keine** Notizen, keine Watchlist,
|
||||||
|
* keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat. Die Seite ist
|
||||||
|
* nur erreichbar, wenn der Nutzer sie freigeschaltet hat *und* der Betreiber
|
||||||
|
* öffentliche Profile instanzweit erlaubt.
|
||||||
|
*/
|
||||||
|
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { profileSlug: slug, publicProfile: true, isActive: true },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
return user ? { title: `${user.name} · Profil` } : { title: "Profil" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PublicProfilePage({ params }: { params: Params }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!settings.publicProfilesEnabled) notFound();
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { profileSlug: slug, publicProfile: true, isActive: true },
|
||||||
|
select: { id: true, name: true, createdAt: true },
|
||||||
|
});
|
||||||
|
if (!user) notFound();
|
||||||
|
|
||||||
|
const [totals, mostWatched, topRated, recent] = await Promise.all([
|
||||||
|
getTotals(user.id),
|
||||||
|
getMostWatched(user.id, 6),
|
||||||
|
getTopRated(user.id, 6),
|
||||||
|
prisma.watch.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { watchedAt: "desc" },
|
||||||
|
take: 8,
|
||||||
|
distinct: ["titleId"],
|
||||||
|
include: {
|
||||||
|
title: { select: { id: true, title: true, posterPath: true, type: true } },
|
||||||
|
episode: { select: { seasonNumber: true, episodeNumber: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col">
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-4 sm:px-6">
|
||||||
|
<Link href="/" aria-label="Flimmer">
|
||||||
|
<FlimmerLogo />
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main id="inhalt" className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
|
||||||
|
<header className="fl-card relative overflow-hidden p-6 sm:p-8">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -right-20 -top-28 h-64 w-64 rounded-full opacity-25 blur-3xl"
|
||||||
|
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||||
|
Öffentliches Profil
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-semibold tracking-tight">{user.name}</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Dabei seit {user.createdAt.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Stat label="Schauzeit" value={formatMinutes(totals.minutes)} />
|
||||||
|
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
|
||||||
|
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
|
||||||
|
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
|
||||||
|
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
|
||||||
|
{recent.map((watch) => (
|
||||||
|
<li key={watch.id}>
|
||||||
|
<Poster path={watch.title.posterPath} alt={watch.title.title} size="w185" />
|
||||||
|
<p className="mt-1.5 line-clamp-2 text-xs font-medium leading-snug">
|
||||||
|
{watch.title.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
{formatRelative(watch.watchedAt)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||||
|
<TitleList heading="Die meiste Zeit" items={mostWatched} showMinutes />
|
||||||
|
<TitleList heading="Bestbewertet" items={topRated} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totals.minutes === 0 && (
|
||||||
|
<p className="fl-card mt-6 p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||||
|
Hier ist noch nichts zu sehen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 text-center text-xs text-[var(--fl-faint)] sm:px-6">
|
||||||
|
Erstellt mit Flimmer · Metadaten von TMDB
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||||
|
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleList({
|
||||||
|
heading,
|
||||||
|
items,
|
||||||
|
showMinutes = false,
|
||||||
|
}: {
|
||||||
|
heading: string;
|
||||||
|
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number }[];
|
||||||
|
showMinutes?: boolean;
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">{heading}</h2>
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li key={item.titleId} className="flex items-center gap-3">
|
||||||
|
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="w-10 shrink-0">
|
||||||
|
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title}</span>
|
||||||
|
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
|
||||||
|
{showMinutes ? formatMinutes(item.minutes) : item.rating ? `${item.rating}/10` : "—"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { requireAdmin } from "@/lib/auth";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { AdminSettings, UserRow } from "@/components/settings/Forms";
|
||||||
|
import { formatRelative } from "@/lib/labels";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Verwaltung" };
|
||||||
|
|
||||||
|
export default async function AdminPage() {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
const [users, lastRun, catalog] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
_count: { select: { entries: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.syncRun.findFirst({ orderBy: { startedAt: "desc" } }),
|
||||||
|
Promise.all([
|
||||||
|
prisma.title.count(),
|
||||||
|
prisma.episode.count(),
|
||||||
|
prisma.watch.count(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [titleCount, episodeCount, watchCount] = catalog;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell user={admin} instanceName={settings.instanceName}>
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verwaltung</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
Einstellungen der Instanz, Konten und Hintergrundlauf.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Instanz</h2>
|
||||||
|
<AdminSettings
|
||||||
|
registrationEnabled={settings.registrationEnabled}
|
||||||
|
publicProfilesEnabled={settings.publicProfilesEnabled}
|
||||||
|
instanceName={settings.instanceName}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Zustand</h2>
|
||||||
|
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<Fact label="Konten" value={String(users.length)} />
|
||||||
|
<Fact label="Titel im Katalog" value={titleCount.toLocaleString("de-DE")} />
|
||||||
|
<Fact label="Episoden" value={episodeCount.toLocaleString("de-DE")} />
|
||||||
|
<Fact label="Sichtungen" value={watchCount.toLocaleString("de-DE")} />
|
||||||
|
<Fact
|
||||||
|
label="TMDB"
|
||||||
|
value={env.tmdb.isConfigured ? "eingerichtet" : "kein Schlüssel"}
|
||||||
|
/>
|
||||||
|
<Fact label="SMTP" value={env.smtp.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
||||||
|
<Fact
|
||||||
|
label="Zeitplan"
|
||||||
|
value={
|
||||||
|
env.scheduler.enabled
|
||||||
|
? `täglich ${String(env.scheduler.hour).padStart(2, "0")}:05 Uhr`
|
||||||
|
: "abgeschaltet"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Fact label="OIDC" value={env.oidc.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="fl-card p-5">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Letzter Abgleich</h2>
|
||||||
|
{lastRun ? (
|
||||||
|
<p className="text-sm text-[var(--fl-muted)]">
|
||||||
|
{formatRelative(lastRun.startedAt)} ·{" "}
|
||||||
|
<span style={{ color: lastRun.ok ? "var(--fl-success)" : "var(--fl-danger)" }}>
|
||||||
|
{lastRun.ok ? "erfolgreich" : "mit Fehlern"}
|
||||||
|
</span>
|
||||||
|
{lastRun.message ? ` · ${lastRun.message}` : ""}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--fl-faint)]">Noch kein Abgleich gelaufen.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="fl-card mt-6 p-5">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold">Konten</h2>
|
||||||
|
<ul className="divide-y divide-[var(--fl-border)]">
|
||||||
|
{users.map((user) => (
|
||||||
|
<UserRow
|
||||||
|
key={user.id}
|
||||||
|
user={{
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
isActive: user.isActive,
|
||||||
|
entries: user._count.entries,
|
||||||
|
}}
|
||||||
|
isSelf={user.id === admin.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fact({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||||
|
<dd className="mt-0.5 font-medium">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet den Service Worker an.
|
||||||
|
*
|
||||||
|
* Nur in der Produktion: im Entwicklungsmodus würde ein Zwischenspeicher
|
||||||
|
* dauernd veraltete Bündel ausliefern und jede Änderung verschleiern.
|
||||||
|
*/
|
||||||
|
export function ServiceWorkerRegistration() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV !== "production") return;
|
||||||
|
if (!("serviceWorker" in navigator)) return;
|
||||||
|
|
||||||
|
const register = () => {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch((error) => {
|
||||||
|
console.warn("Service Worker konnte nicht angemeldet werden:", error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "complete") register();
|
||||||
|
else window.addEventListener("load", register, { once: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,757 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import {
|
||||||
|
changePasswordAction,
|
||||||
|
createApiKeyAction,
|
||||||
|
deleteChannelAction,
|
||||||
|
importDataAction,
|
||||||
|
revokeApiKeyAction,
|
||||||
|
runSyncAction,
|
||||||
|
saveChannelAction,
|
||||||
|
setInstanceNameAction,
|
||||||
|
setPublicProfilesAction,
|
||||||
|
setRegistrationAction,
|
||||||
|
setUserActiveAction,
|
||||||
|
setUserRoleAction,
|
||||||
|
testChannelAction,
|
||||||
|
toggleChannelAction,
|
||||||
|
updateProfileAction,
|
||||||
|
} from "@/app/actions/settings";
|
||||||
|
import { useToasts } from "@/components/Toasts";
|
||||||
|
import { formatDate } from "@/lib/labels";
|
||||||
|
|
||||||
|
/** Wiederverwendete Bausteine der Einstellungsseiten. */
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
...props
|
||||||
|
}: React.InputHTMLAttributes<HTMLInputElement> & { id: string; label: string; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor={id} className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input id={id} name={id} className="fl-input" {...props} />
|
||||||
|
{hint && <p className="text-xs text-[var(--fl-faint)]">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex cursor-pointer items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(event.target.checked)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={clsx(
|
||||||
|
"mt-0.5 h-5 w-9 shrink-0 rounded-full p-0.5 transition-colors",
|
||||||
|
checked ? "" : "bg-[var(--fl-surface-2)] ring-1 ring-[var(--fl-border-strong)]",
|
||||||
|
)}
|
||||||
|
style={checked ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"block h-4 w-4 rounded-full bg-[var(--fl-text)] transition-transform",
|
||||||
|
checked && "translate-x-4",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium">{label}</span>
|
||||||
|
{hint && <span className="block text-xs text-[var(--fl-faint)]">{hint}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Profil
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ProfileForm({
|
||||||
|
user,
|
||||||
|
publicProfilesEnabled,
|
||||||
|
}: {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
region: string;
|
||||||
|
publicProfile: boolean;
|
||||||
|
profileSlug: string | null;
|
||||||
|
notifyNewEpisode: boolean;
|
||||||
|
notifyRelease: boolean;
|
||||||
|
notifyAvailability: boolean;
|
||||||
|
};
|
||||||
|
publicProfilesEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { report } = useToasts();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [state, setState] = useState(user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(
|
||||||
|
await updateProfileAction({
|
||||||
|
name: state.name,
|
||||||
|
region: state.region,
|
||||||
|
publicProfile: state.publicProfile,
|
||||||
|
profileSlug: state.profileSlug || null,
|
||||||
|
notifyNewEpisode: state.notifyNewEpisode,
|
||||||
|
notifyRelease: state.notifyRelease,
|
||||||
|
notifyAvailability: state.notifyAvailability,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="space-y-5"
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
id="name"
|
||||||
|
label="Anzeigename"
|
||||||
|
value={state.name}
|
||||||
|
onChange={(event) => setState({ ...state, name: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-sm font-medium text-[var(--fl-muted)]">E-Mail-Adresse</label>
|
||||||
|
<p className="fl-input text-[var(--fl-faint)]">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
id="region"
|
||||||
|
label="Region"
|
||||||
|
value={state.region}
|
||||||
|
maxLength={2}
|
||||||
|
onChange={(event) => setState({ ...state, region: event.target.value.toUpperCase() })}
|
||||||
|
hint="Zwei Buchstaben nach ISO 3166 — bestimmt, welche Streamingdienste angezeigt werden (DE, AT, CH …)."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="mb-1 text-sm font-medium text-[var(--fl-muted)]">
|
||||||
|
Wovon soll Flimmer dir berichten?
|
||||||
|
</legend>
|
||||||
|
<Toggle
|
||||||
|
checked={state.notifyNewEpisode}
|
||||||
|
onChange={(value) => setState({ ...state, notifyNewEpisode: value })}
|
||||||
|
label="Neue Episoden"
|
||||||
|
hint="Wenn bei einer Serie in deiner Bibliothek eine Folge dazukommt."
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
checked={state.notifyRelease}
|
||||||
|
onChange={(value) => setState({ ...state, notifyRelease: value })}
|
||||||
|
label="Neue Releases"
|
||||||
|
hint="Kinostart oder Heimkinostart eines Films auf deiner Watchlist."
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
checked={state.notifyAvailability}
|
||||||
|
onChange={(value) => setState({ ...state, notifyAvailability: value })}
|
||||||
|
label="Neu verfügbar"
|
||||||
|
hint="Wenn ein Watchlist-Titel auf einem Dienst in deiner Region auftaucht."
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{publicProfilesEnabled && (
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend className="mb-1 text-sm font-medium text-[var(--fl-muted)]">
|
||||||
|
Öffentliches Profil
|
||||||
|
</legend>
|
||||||
|
<Toggle
|
||||||
|
checked={state.publicProfile}
|
||||||
|
onChange={(value) => setState({ ...state, publicProfile: value })}
|
||||||
|
label="Profil öffentlich zeigen"
|
||||||
|
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
|
||||||
|
/>
|
||||||
|
{state.publicProfile && (
|
||||||
|
<Field
|
||||||
|
id="profileSlug"
|
||||||
|
label="Adresse"
|
||||||
|
value={state.profileSlug ?? ""}
|
||||||
|
onChange={(event) => setState({ ...state, profileSlug: event.target.value })}
|
||||||
|
placeholder="dein-name"
|
||||||
|
hint="Erreichbar unter /u/<adresse>"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||||
|
{pending ? "Einen Moment …" : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordForm() {
|
||||||
|
const { report } = useToasts();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={(formData) =>
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await changePasswordAction({
|
||||||
|
current: String(formData.get("current") ?? ""),
|
||||||
|
next: String(formData.get("next") ?? ""),
|
||||||
|
repeat: String(formData.get("repeat") ?? ""),
|
||||||
|
});
|
||||||
|
report(result);
|
||||||
|
if (result.ok) window.location.href = "/anmelden";
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<Field id="current" label="Bisheriges Passwort" type="password" autoComplete="current-password" required />
|
||||||
|
<Field
|
||||||
|
id="next"
|
||||||
|
label="Neues Passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
hint="Mindestens 10 Zeichen."
|
||||||
|
/>
|
||||||
|
<Field id="repeat" label="Neues Passwort wiederholen" type="password" autoComplete="new-password" required />
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
Nach dem Wechsel wirst du auf allen Geräten abgemeldet.
|
||||||
|
</p>
|
||||||
|
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost">
|
||||||
|
Passwort ändern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API-Schlüssel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ApiKeys({
|
||||||
|
keys,
|
||||||
|
}: {
|
||||||
|
keys: { id: string; name: string; prefix: string; lastUsedAt: Date | null; createdAt: Date }[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { report } = useToasts();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [secret, setSecret] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<form
|
||||||
|
action={(formData) => {
|
||||||
|
const name = String(formData.get("keyName") ?? "").trim();
|
||||||
|
if (!name) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await createApiKeyAction(name);
|
||||||
|
report(result);
|
||||||
|
if (result.secret) setSecret(result.secret);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex flex-wrap items-end gap-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-48 flex-1 space-y-1.5">
|
||||||
|
<label htmlFor="keyName" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||||
|
Neuer Schlüssel
|
||||||
|
</label>
|
||||||
|
<input id="keyName" name="keyName" placeholder="z. B. Handy-Skript" className="fl-input" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{secret && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-xl border p-3"
|
||||||
|
style={{
|
||||||
|
borderColor: "color-mix(in oklab, var(--fl-warning) 45%, transparent)",
|
||||||
|
backgroundColor: "color-mix(in oklab, var(--fl-warning) 10%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-[var(--fl-warning)]">
|
||||||
|
Einmalig sichtbar — jetzt kopieren:
|
||||||
|
</p>
|
||||||
|
<code className="mt-2 block break-all rounded-lg bg-[var(--fl-surface-2)] p-2 font-mono text-xs">
|
||||||
|
{secret}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard?.writeText(secret);
|
||||||
|
report({ ok: true, message: "In die Zwischenablage kopiert" });
|
||||||
|
}}
|
||||||
|
className="fl-btn fl-btn-ghost mt-2 px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--fl-faint)]">Noch kein Schlüssel angelegt.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--fl-border)]">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<li key={key.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{key.name}</p>
|
||||||
|
<p className="font-mono text-xs text-[var(--fl-faint)]">
|
||||||
|
{key.prefix}… · angelegt {formatDate(key.createdAt)}
|
||||||
|
{key.lastUsedAt ? ` · zuletzt benutzt ${formatDate(key.lastUsedAt)}` : " · nie benutzt"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await revokeApiKeyAction(key.id));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||||
|
>
|
||||||
|
Zurückziehen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benachrichtigungskanäle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CHANNEL_FIELDS: Record<string, { key: string; label: string; placeholder: string }[]> = {
|
||||||
|
EMAIL: [{ key: "email", label: "Abweichende Adresse", placeholder: "leer = Konto-Adresse" }],
|
||||||
|
NTFY: [
|
||||||
|
{ key: "server", label: "Server", placeholder: "https://ntfy.sh" },
|
||||||
|
{ key: "topic", label: "Topic", placeholder: "flimmer-abc123" },
|
||||||
|
{ key: "token", label: "Zugriffstoken", placeholder: "optional" },
|
||||||
|
],
|
||||||
|
GOTIFY: [
|
||||||
|
{ key: "url", label: "Server", placeholder: "https://gotify.example.com" },
|
||||||
|
{ key: "token", label: "Anwendungs-Token", placeholder: "AxB..." },
|
||||||
|
],
|
||||||
|
WEBHOOK: [
|
||||||
|
{ key: "webhookUrl", label: "Adresse", placeholder: "https://discord.com/api/webhooks/…" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_LABEL: Record<string, string> = {
|
||||||
|
EMAIL: "E-Mail",
|
||||||
|
NTFY: "ntfy",
|
||||||
|
GOTIFY: "Gotify",
|
||||||
|
WEBHOOK: "Webhook (Discord, Slack, …)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChannelList({
|
||||||
|
channels,
|
||||||
|
smtpReady,
|
||||||
|
}: {
|
||||||
|
channels: { id: string; kind: string; name: string; config: Record<string, string>; enabled: boolean }[];
|
||||||
|
smtpReady: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { report } = useToasts();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [kind, setKind] = useState<string>("NTFY");
|
||||||
|
const [config, setConfig] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{channels.length > 0 && (
|
||||||
|
<ul className="divide-y divide-[var(--fl-border)]">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<li key={channel.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{channel.name}
|
||||||
|
<span className="ml-2 text-xs font-normal text-[var(--fl-faint)]">
|
||||||
|
{CHANNEL_LABEL[channel.kind] ?? channel.kind}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||||
|
{Object.entries(channel.config)
|
||||||
|
.filter(([key]) => key !== "token")
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join(" · ") || "ohne weitere Angaben"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
checked={channel.enabled}
|
||||||
|
onChange={(value) =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await toggleChannelAction(channel.id, value));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label=""
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await testChannelAction(channel.id));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
Testen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await deleteChannelAction(channel.id));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
action={(formData) => {
|
||||||
|
const name = String(formData.get("channelName") ?? "").trim();
|
||||||
|
if (!name) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await saveChannelAction({ kind, name, config }));
|
||||||
|
setConfig({});
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="space-y-4 border-t border-[var(--fl-border)] pt-5"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold">Kanal hinzufügen</h3>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="kind" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||||
|
Art
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="kind"
|
||||||
|
value={kind}
|
||||||
|
onChange={(event) => {
|
||||||
|
setKind(event.target.value);
|
||||||
|
setConfig({});
|
||||||
|
}}
|
||||||
|
className="fl-input"
|
||||||
|
>
|
||||||
|
{Object.entries(CHANNEL_LABEL).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{kind === "EMAIL" && !smtpReady && (
|
||||||
|
<p className="text-xs text-[var(--fl-warning)]">
|
||||||
|
Für diese Instanz ist kein SMTP-Zugang eingerichtet — E-Mails können nicht verschickt
|
||||||
|
werden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field id="channelName" label="Name" placeholder="z. B. Handy" required />
|
||||||
|
|
||||||
|
{(CHANNEL_FIELDS[kind] ?? []).map((field) => (
|
||||||
|
<div key={field.key} className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor={`cfg-${field.key}`}
|
||||||
|
className="block text-sm font-medium text-[var(--fl-muted)]"
|
||||||
|
>
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`cfg-${field.key}`}
|
||||||
|
value={config[field.key] ?? ""}
|
||||||
|
onChange={(event) => setConfig({ ...config, [field.key]: event.target.value })}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="fl-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||||
|
Kanal anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ImportForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { report } = useToasts();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [format, setFormat] = useState<"csv" | "trakt">("csv");
|
||||||
|
const [summary, setSummary] = useState<string[] | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(["csv", "trakt"] as const).map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormat(option)}
|
||||||
|
aria-pressed={format === option}
|
||||||
|
className="rounded-full border px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
style={
|
||||||
|
format === option
|
||||||
|
? {
|
||||||
|
backgroundImage: "var(--fl-gradient)",
|
||||||
|
color: "var(--fl-on-accent)",
|
||||||
|
borderColor: "transparent",
|
||||||
|
}
|
||||||
|
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option === "csv" ? "CSV" : "Trakt.tv (JSON)"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--fl-muted)]">
|
||||||
|
{format === "csv"
|
||||||
|
? "Die Kopfzeile braucht mindestens eine Spalte mit dem Titel. Erkannt werden außerdem Typ, Status, Bewertung, TMDB, Jahr und Notiz."
|
||||||
|
: "Ein Export aus Trakt.tv — Watchlist, History, Ratings oder Collection. Flimmer sucht selbst die Einträge heraus."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={format === "csv" ? ".csv,text/csv" : ".json,application/json"}
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setSummary(null);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const content = await file.text();
|
||||||
|
const result = await importDataAction(format, content);
|
||||||
|
report({ ok: result.ok, message: result.message });
|
||||||
|
|
||||||
|
if (result.summary) {
|
||||||
|
setSummary([
|
||||||
|
`${result.summary.processed} Zeilen gelesen`,
|
||||||
|
`${result.summary.added} neu aufgenommen`,
|
||||||
|
`${result.summary.skipped} übersprungen (schon vorhanden oder unvollständig)`,
|
||||||
|
...result.summary.errors.slice(0, 5),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={pending}
|
||||||
|
className="fl-input file:mr-3 file:rounded-lg file:border-0 file:bg-[var(--fl-surface-2)] file:px-3 file:py-1.5 file:text-sm file:text-[var(--fl-text)]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
Der Import überschreibt nichts: Titel, die schon in deiner Bibliothek liegen, bleiben
|
||||||
|
unangetastet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<ul className="rounded-xl border border-[var(--fl-border)] p-3 text-sm text-[var(--fl-muted)]">
|
||||||
|
{summary.map((line, index) => (
|
||||||
|
<li key={index}>{line}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Verwaltung
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function AdminSettings({
|
||||||
|
registrationEnabled,
|
||||||
|
publicProfilesEnabled,
|
||||||
|
instanceName,
|
||||||
|
}: {
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
publicProfilesEnabled: boolean;
|
||||||
|
instanceName: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { report } = useToasts();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<form
|
||||||
|
action={(formData) =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await setInstanceNameAction(String(formData.get("instanceName") ?? "")));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex flex-wrap items-end gap-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-48 flex-1">
|
||||||
|
<Field id="instanceName" label="Name der Instanz" defaultValue={instanceName} />
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
checked={registrationEnabled}
|
||||||
|
onChange={(value) =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await setRegistrationAction(value));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Registrierung offen"
|
||||||
|
hint="Solange es kein einziges Konto gibt, bleibt die Registrierung unabhängig davon offen."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
checked={publicProfilesEnabled}
|
||||||
|
onChange={(value) =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await setPublicProfilesAction(value));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label="Öffentliche Profile erlauben"
|
||||||
|
hint="Schaltet die Profilseiten instanzweit ab, unabhängig von der Einstellung der Nutzer."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border-t border-[var(--fl-border)] pt-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await runSyncAction());
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="fl-btn fl-btn-primary"
|
||||||
|
>
|
||||||
|
{pending ? "Läuft …" : "Abgleich jetzt starten"}
|
||||||
|
</button>
|
||||||
|
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||||
|
Frischt laufende Serien bei TMDB auf, verschickt fällige Benachrichtigungen und räumt den
|
||||||
|
Papierkorb. Kann eine Weile dauern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserRow({
|
||||||
|
user,
|
||||||
|
isSelf,
|
||||||
|
}: {
|
||||||
|
user: { id: string; name: string; email: string; role: string; isActive: boolean; entries: number };
|
||||||
|
isSelf: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { report } = useToasts();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex flex-wrap items-center gap-3 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{user.name}
|
||||||
|
{user.role === "ADMIN" && (
|
||||||
|
<span
|
||||||
|
className="ml-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
||||||
|
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||||
|
>
|
||||||
|
Verwaltung
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!user.isActive && (
|
||||||
|
<span className="ml-2 text-[0.65rem] font-semibold text-[var(--fl-danger)]">gesperrt</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||||
|
{user.email} · {user.entries} {user.entries === 1 ? "Titel" : "Titel"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSelf && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await setUserRoleAction(user.id, user.role === "ADMIN" ? "USER" : "ADMIN"));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{user.role === "ADMIN" ? "Rechte entziehen" : "Zur Verwaltung"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await setUserActiveAction(user.id, !user.isActive));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
|
||||||
|
>
|
||||||
|
{user.isActive ? "Sperren" : "Freigeben"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
|
/** Seitennavigation der Einstellungen. */
|
||||||
|
const ITEMS = [
|
||||||
|
{ href: "/einstellungen", label: "Profil" },
|
||||||
|
{ href: "/einstellungen/benachrichtigungen", label: "Benachrichtigungen" },
|
||||||
|
{ href: "/einstellungen/api", label: "API-Schlüssel" },
|
||||||
|
{ href: "/einstellungen/daten", label: "Import und Export" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsNav({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Einstellungen"
|
||||||
|
className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible"
|
||||||
|
>
|
||||||
|
{ITEMS.map((item) => {
|
||||||
|
const active = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-[var(--fl-surface-2)] text-[var(--fl-text)]"
|
||||||
|
: "text-[var(--fl-muted)] hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Link
|
||||||
|
href="/verwaltung"
|
||||||
|
className="shrink-0 rounded-lg px-3 py-2 text-sm font-medium text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
|
||||||
|
>
|
||||||
|
Verwaltung
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Wird von Next beim Start des Servers einmal ausgeführt.
|
||||||
|
*
|
||||||
|
* Hier hängt der eingebaute Zeitplan für den täglichen Abgleich. Er läuft nur
|
||||||
|
* in der Node-Laufzeit (nicht im Edge-Runtime) und nur, wenn ENABLE_SCHEDULER
|
||||||
|
* gesetzt ist — bei mehreren App-Instanzen soll ihn nur eine übernehmen.
|
||||||
|
*/
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||||
|
|
||||||
|
const { startScheduler } = await import("./lib/sync");
|
||||||
|
startScheduler();
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getApiUser, type SessionUser } from "./auth";
|
||||||
|
import { LibraryError } from "./library";
|
||||||
|
import { UniverseError } from "./universes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsames Gerüst der REST-API.
|
||||||
|
*
|
||||||
|
* Zwei Dinge macht jeder Endpunkt gleich: die Anmeldung prüfen (Bearer-Token
|
||||||
|
* oder Sitzungscookie — dieselben Endpunkte bedienen Skripte und das eigene
|
||||||
|
* Frontend) und Fehler in eine einheitliche Form bringen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ApiHandler<T = unknown> = (
|
||||||
|
request: Request,
|
||||||
|
user: SessionUser,
|
||||||
|
context: { params: Promise<Record<string, string>> },
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
export function apiError(message: string, status = 400, details?: unknown) {
|
||||||
|
return NextResponse.json({ error: message, ...(details ? { details } : {}) }, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umhüllt einen Endpunkt: Anmeldung, Fehlerbehandlung, JSON-Antwort.
|
||||||
|
* Gibt der Handler bereits eine Response zurück, wird sie durchgereicht.
|
||||||
|
*/
|
||||||
|
export function withApi<T>(handler: ApiHandler<T>) {
|
||||||
|
return async (request: Request, context: { params: Promise<Record<string, string>> }) => {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Nicht angemeldet. Schicke einen API-Schlüssel im Kopf: Authorization: Bearer <schlüssel>",
|
||||||
|
},
|
||||||
|
{ status: 401, headers: { "www-authenticate": "Bearer" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await handler(request, user, context);
|
||||||
|
if (result instanceof NextResponse) return result;
|
||||||
|
return NextResponse.json(result ?? { ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return apiError("Die Anfrage ist unvollständig oder falsch aufgebaut.", 422, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof LibraryError || error instanceof UniverseError) {
|
||||||
|
return apiError(error.message, 400);
|
||||||
|
}
|
||||||
|
console.error("API-Fehler:", error);
|
||||||
|
return apiError("Interner Fehler.", 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest und prüft den JSON-Rumpf einer Anfrage. */
|
||||||
|
export async function readJson<S extends z.ZodTypeAny>(
|
||||||
|
request: Request,
|
||||||
|
schema: S,
|
||||||
|
): Promise<z.infer<S>> {
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await request.json();
|
||||||
|
} catch {
|
||||||
|
throw new z.ZodError([
|
||||||
|
{
|
||||||
|
code: "custom",
|
||||||
|
path: [],
|
||||||
|
message: "Der Rumpf der Anfrage ist kein gültiges JSON.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return schema.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seitenweise Abfrage: `?limit=50&offset=0` */
|
||||||
|
export function readPaging(request: Request, defaultLimit = 50, maxLimit = 200) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const limit = Math.min(
|
||||||
|
maxLimit,
|
||||||
|
Math.max(1, Number.parseInt(url.searchParams.get("limit") ?? "", 10) || defaultLimit),
|
||||||
|
);
|
||||||
|
const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "", 10) || 0);
|
||||||
|
return { limit, offset };
|
||||||
|
}
|
||||||
@@ -49,6 +49,12 @@ export const env = {
|
|||||||
|
|
||||||
tmdb: {
|
tmdb: {
|
||||||
apiKey: optional("TMDB_API_KEY"),
|
apiKey: optional("TMDB_API_KEY"),
|
||||||
|
/**
|
||||||
|
* Abweichende Adresse der TMDB-API. Gedacht für Instanzen hinter einem
|
||||||
|
* eigenen Proxy und für den Testbetrieb; leer lassen heißt: direkt zu TMDB.
|
||||||
|
*/
|
||||||
|
baseUrl: optional("TMDB_BASE_URL", "https://api.themoviedb.org/3").replace(/\/+$/, ""),
|
||||||
|
imageBaseUrl: optional("TMDB_IMAGE_BASE_URL", "https://image.tmdb.org/t/p").replace(/\/+$/, ""),
|
||||||
accessToken: optional("TMDB_ACCESS_TOKEN"),
|
accessToken: optional("TMDB_ACCESS_TOKEN"),
|
||||||
language: optional("TMDB_LANGUAGE", "de-DE"),
|
language: optional("TMDB_LANGUAGE", "de-DE"),
|
||||||
region: optional("TMDB_REGION", "DE"),
|
region: optional("TMDB_REGION", "DE"),
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import "server-only";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import type { ChannelKind } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigungen.
|
||||||
|
*
|
||||||
|
* Vier Wege, ein Nachrichtenformat: E-Mail, ntfy, Gotify und ein generischer
|
||||||
|
* Webhook (der auch Discord und Slack bedient). Jeder Nutzer richtet sich
|
||||||
|
* seine Kanäle selbst ein — der Betreiber gibt nur den SMTP-Zugang vor.
|
||||||
|
*
|
||||||
|
* Doppelversand verhindert `NotificationLog` über einen Dedupe-Schlüssel:
|
||||||
|
* dieselbe Episode wird nie zweimal gemeldet, egal wie oft der Abgleich läuft.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NotificationMessage = {
|
||||||
|
/** Kurze Betreffzeile. */
|
||||||
|
subject: string;
|
||||||
|
/** Fließtext, eine bis drei Zeilen. */
|
||||||
|
body: string;
|
||||||
|
/** Ziel in Flimmer, z. B. „/titel/abc". */
|
||||||
|
path?: string;
|
||||||
|
/** Verhindert Doppelversand, z. B. „episode:<id>". */
|
||||||
|
dedupeKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelConfig = {
|
||||||
|
/** ntfy: Server-URL, Standard https://ntfy.sh */
|
||||||
|
server?: string;
|
||||||
|
/** ntfy: Topic; Gotify/Webhook: nicht benutzt */
|
||||||
|
topic?: string;
|
||||||
|
/** Gotify: Server-URL */
|
||||||
|
url?: string;
|
||||||
|
/** Gotify: Anwendungs-Token; ntfy: optionaler Zugriffstoken */
|
||||||
|
token?: string;
|
||||||
|
/** Webhook: Zieladresse */
|
||||||
|
webhookUrl?: string;
|
||||||
|
/** E-Mail: abweichende Empfängeradresse */
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Versandwege
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function linkFor(message: NotificationMessage): string | null {
|
||||||
|
return message.path ? `${env.appUrl}${message.path}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmail(to: string, message: NotificationMessage) {
|
||||||
|
if (!env.smtp.isConfigured) {
|
||||||
|
throw new Error("Für diese Instanz ist kein SMTP-Zugang eingerichtet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = nodemailer.createTransport({
|
||||||
|
host: env.smtp.host,
|
||||||
|
port: env.smtp.port,
|
||||||
|
secure: env.smtp.secure,
|
||||||
|
...(env.smtp.user ? { auth: { user: env.smtp.user, pass: env.smtp.password } } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = linkFor(message);
|
||||||
|
await transport.sendMail({
|
||||||
|
from: env.smtp.from,
|
||||||
|
to,
|
||||||
|
subject: `${message.subject} · ${env.appName}`,
|
||||||
|
text: [message.body, link].filter(Boolean).join("\n\n"),
|
||||||
|
html: emailTemplate(message, link),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E-Mail-Vorlage im Flimmer-Look.
|
||||||
|
*
|
||||||
|
* Bewusst schlichtes HTML mit Inline-Stilen — E-Mail-Programme können weder
|
||||||
|
* externe Stylesheets noch CSS-Variablen. Das Logo steckt als Inline-SVG
|
||||||
|
* drin, damit nichts nachgeladen werden muss.
|
||||||
|
*/
|
||||||
|
function emailTemplate(message: NotificationMessage, link: string | null): string {
|
||||||
|
const escape = (value: string) =>
|
||||||
|
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="de"><body style="margin:0;background:#0c0a14;padding:24px;font-family:'Segoe UI',Inter,Helvetica,Arial,sans-serif;color:#f4f1fa">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width:520px;margin:0 auto;background:#171424;border:1px solid #2e2745;border-radius:16px">
|
||||||
|
<tr><td style="padding:24px 24px 8px">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
||||||
|
<td style="padding-right:10px">
|
||||||
|
<svg width="30" height="30" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs><linearGradient id="g" x1="20" y1="24" x2="108" y2="102" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#ffc46b"/><stop offset=".38" stop-color="#ffb454"/><stop offset="1" stop-color="#ff4d9d"/>
|
||||||
|
</linearGradient></defs>
|
||||||
|
<rect x="22" y="26" width="84" height="64" rx="18" fill="none" stroke="url(#g)" stroke-width="7"/>
|
||||||
|
<path d="M55.5 45.5 L80 58 L55.5 70.5 Z" fill="url(#g)" stroke="url(#g)" stroke-width="6.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M64 90 V101 M49 105.5 H79" stroke="url(#g)" stroke-width="6.5" stroke-linecap="round" stroke-opacity=".62"/>
|
||||||
|
</svg>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:20px;font-weight:700;letter-spacing:-.01em;color:#ffb454">Flimmer</td>
|
||||||
|
</tr></table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:8px 24px 4px;font-size:18px;font-weight:600">${escape(message.subject)}</td></tr>
|
||||||
|
<tr><td style="padding:4px 24px 20px;font-size:15px;line-height:1.55;color:#aca5c2">${escape(message.body).replace(/\n/g, "<br>")}</td></tr>
|
||||||
|
${
|
||||||
|
link
|
||||||
|
? `<tr><td style="padding:0 24px 24px">
|
||||||
|
<a href="${link}" style="display:inline-block;background:#ffb454;color:#1a1024;text-decoration:none;font-weight:600;padding:10px 18px;border-radius:10px">In Flimmer ansehen</a>
|
||||||
|
</td></tr>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
<tr><td style="padding:0 24px 22px;font-size:12px;color:#7c7594">
|
||||||
|
Diese Nachricht kommt von deiner eigenen Flimmer-Instanz. Kanäle verwaltest du in den Einstellungen.
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNtfy(config: ChannelConfig, message: NotificationMessage) {
|
||||||
|
const server = (config.server || "https://ntfy.sh").replace(/\/+$/, "");
|
||||||
|
if (!config.topic) throw new Error("Für ntfy fehlt das Topic.");
|
||||||
|
|
||||||
|
const link = linkFor(message);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Title: message.subject,
|
||||||
|
Tags: "clapper",
|
||||||
|
};
|
||||||
|
if (link) headers.Click = link;
|
||||||
|
if (config.token) headers.Authorization = `Bearer ${config.token}`;
|
||||||
|
|
||||||
|
const response = await fetch(`${server}/${encodeURIComponent(config.topic)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: message.body,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`ntfy antwortete mit ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendGotify(config: ChannelConfig, message: NotificationMessage) {
|
||||||
|
if (!config.url || !config.token) throw new Error("Für Gotify fehlen Adresse oder Token.");
|
||||||
|
const base = config.url.replace(/\/+$/, "");
|
||||||
|
const link = linkFor(message);
|
||||||
|
|
||||||
|
const response = await fetch(`${base}/message?token=${encodeURIComponent(config.token)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: message.subject,
|
||||||
|
message: [message.body, link].filter(Boolean).join("\n\n"),
|
||||||
|
priority: 4,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Gotify antwortete mit ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generischer Webhook.
|
||||||
|
*
|
||||||
|
* Der Rumpf trägt sowohl `content` (Discord) als auch `text` (Slack und
|
||||||
|
* Mattermost) und daneben die Einzelteile — damit funktioniert derselbe Kanal
|
||||||
|
* für alle drei, ohne dass jemand ein Format auswählen muss.
|
||||||
|
*/
|
||||||
|
async function sendWebhook(config: ChannelConfig, message: NotificationMessage) {
|
||||||
|
if (!config.webhookUrl) throw new Error("Für den Webhook fehlt die Adresse.");
|
||||||
|
|
||||||
|
const link = linkFor(message);
|
||||||
|
const text = [`**${message.subject}**`, message.body, link].filter(Boolean).join("\n");
|
||||||
|
|
||||||
|
const response = await fetch(config.webhookUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: text,
|
||||||
|
text,
|
||||||
|
username: env.appName,
|
||||||
|
subject: message.subject,
|
||||||
|
body: message.body,
|
||||||
|
url: link,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Der Webhook antwortete mit ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Versand
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function sendThroughChannel(
|
||||||
|
kind: ChannelKind,
|
||||||
|
config: ChannelConfig,
|
||||||
|
fallbackEmail: string,
|
||||||
|
message: NotificationMessage,
|
||||||
|
): Promise<void> {
|
||||||
|
switch (kind) {
|
||||||
|
case "EMAIL":
|
||||||
|
return sendEmail(config.email || fallbackEmail, message);
|
||||||
|
case "NTFY":
|
||||||
|
return sendNtfy(config, message);
|
||||||
|
case "GOTIFY":
|
||||||
|
return sendGotify(config, message);
|
||||||
|
case "WEBHOOK":
|
||||||
|
return sendWebhook(config, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotifyResult = { sent: number; skipped: number; failed: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt eine Nachricht über alle aktiven Kanäle eines Nutzers.
|
||||||
|
* Bereits gemeldete Nachrichten (gleicher Dedupe-Schlüssel) werden übersprungen.
|
||||||
|
*/
|
||||||
|
export async function notifyUser(
|
||||||
|
userId: string,
|
||||||
|
message: NotificationMessage,
|
||||||
|
): Promise<NotifyResult> {
|
||||||
|
const existing = await prisma.notificationLog.findUnique({
|
||||||
|
where: { userId_dedupeKey: { userId, dedupeKey: message.dedupeKey } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) return { sent: 0, skipped: 1, failed: 0 };
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { email: true, channels: { where: { enabled: true } } },
|
||||||
|
});
|
||||||
|
if (!user || user.channels.length === 0) return { sent: 0, skipped: 1, failed: 0 };
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const channel of user.channels) {
|
||||||
|
try {
|
||||||
|
await sendThroughChannel(
|
||||||
|
channel.kind,
|
||||||
|
(channel.config ?? {}) as ChannelConfig,
|
||||||
|
user.email,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
sent += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
errors.push(`${channel.name}: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auch fehlgeschlagene Versuche werden protokolliert — sonst versucht es der
|
||||||
|
// nächste Lauf endlos erneut und der Kanal wird zugespammt, sobald er wieder
|
||||||
|
// erreichbar ist.
|
||||||
|
await prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
dedupeKey: message.dedupeKey,
|
||||||
|
subject: message.subject,
|
||||||
|
body: message.body,
|
||||||
|
ok: failed === 0,
|
||||||
|
error: errors.length > 0 ? errors.join(" | ") : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sent, skipped: 0, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Testnachricht für die Einstellungsseite. */
|
||||||
|
export async function sendTestNotification(userId: string, channelId: string): Promise<void> {
|
||||||
|
const channel = await prisma.notificationChannel.findFirst({
|
||||||
|
where: { id: channelId, userId },
|
||||||
|
include: { user: { select: { email: true, name: true } } },
|
||||||
|
});
|
||||||
|
if (!channel) throw new Error("Diesen Kanal gibt es nicht.");
|
||||||
|
|
||||||
|
await sendThroughChannel(
|
||||||
|
channel.kind,
|
||||||
|
(channel.config ?? {}) as ChannelConfig,
|
||||||
|
channel.user.email,
|
||||||
|
{
|
||||||
|
subject: "Testnachricht von Flimmer",
|
||||||
|
body: `Hallo ${channel.user.name}, dieser Kanal funktioniert. Ab jetzt meldet sich Flimmer hier, wenn eine neue Episode ansteht.`,
|
||||||
|
path: "/einstellungen/benachrichtigungen",
|
||||||
|
dedupeKey: `test:${channelId}:${Date.now()}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
|
||||||
|
import { notifyUser } from "./notify";
|
||||||
|
import { episodeCode } from "./labels";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hintergrundlauf.
|
||||||
|
*
|
||||||
|
* Einmal täglich (oder von außen angestoßen) werden laufende Serien bei TMDB
|
||||||
|
* aufgefrischt, neue Episoden gemeldet, Watchlist-Filme auf Verfügbarkeit
|
||||||
|
* geprüft und der Papierkorb geleert.
|
||||||
|
*
|
||||||
|
* Grundsatz: Der Lauf darf nie an einem einzelnen Titel scheitern. Fehler
|
||||||
|
* werden gezählt und protokolliert, der Lauf macht weiter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Aufbewahrungsfrist des Papierkorbs. */
|
||||||
|
export const TRASH_RETENTION_DAYS = 30;
|
||||||
|
|
||||||
|
export type SyncStats = {
|
||||||
|
titlesChecked: number;
|
||||||
|
titlesUpdated: number;
|
||||||
|
newEpisodes: number;
|
||||||
|
notificationsSent: number;
|
||||||
|
entriesPurged: number;
|
||||||
|
sessionsPurged: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serien, die einen Abgleich lohnen: laufende Produktionen, die jemand
|
||||||
|
* verfolgt. Abgeschlossene Serien bekommen keine Episoden mehr — die einmal
|
||||||
|
* jährlich mitzunehmen genügt, deshalb der großzügige Altersschwellwert.
|
||||||
|
*/
|
||||||
|
async function titlesDueForSync(limit: number) {
|
||||||
|
const stale = new Date(Date.now() - 24 * 3_600_000);
|
||||||
|
const veryStale = new Date(Date.now() - 30 * 24 * 3_600_000);
|
||||||
|
|
||||||
|
return prisma.title.findMany({
|
||||||
|
where: {
|
||||||
|
tmdbId: { not: null },
|
||||||
|
entries: { some: { deletedAt: null, status: { not: "DROPPED" } } },
|
||||||
|
OR: [
|
||||||
|
{ type: "SERIES", inProduction: true, syncedAt: { lt: stale } },
|
||||||
|
{ syncedAt: { lt: veryStale } },
|
||||||
|
{ syncedAt: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { syncedAt: { sort: "asc", nulls: "first" } },
|
||||||
|
take: limit,
|
||||||
|
select: { id: true, type: true, tmdbId: true, title: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet neu aufgetauchte Episoden.
|
||||||
|
*
|
||||||
|
* „Neu" heißt: die Episode ist erst bei diesem Lauf in den Katalog gekommen
|
||||||
|
* und liegt zeitlich nicht in ferner Vergangenheit — sonst würde der erste
|
||||||
|
* Abgleich einer alten Serie hundert Meldungen auslösen.
|
||||||
|
*/
|
||||||
|
async function notifyNewEpisodes(
|
||||||
|
titleId: string,
|
||||||
|
titleName: string,
|
||||||
|
knownEpisodeIds: Set<string>,
|
||||||
|
): Promise<{ found: number; sent: number }> {
|
||||||
|
const horizon = new Date(Date.now() - 7 * 86_400_000);
|
||||||
|
|
||||||
|
const episodes = await prisma.episode.findMany({
|
||||||
|
where: {
|
||||||
|
titleId,
|
||||||
|
seasonNumber: { gt: SPECIALS_SEASON },
|
||||||
|
id: { notIn: [...knownEpisodeIds] },
|
||||||
|
airDate: { gte: horizon },
|
||||||
|
},
|
||||||
|
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||||
|
select: { id: true, name: true, seasonNumber: true, episodeNumber: true, airDate: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (episodes.length === 0) return { found: 0, sent: 0 };
|
||||||
|
|
||||||
|
const watchers = await prisma.libraryEntry.findMany({
|
||||||
|
where: {
|
||||||
|
titleId,
|
||||||
|
deletedAt: null,
|
||||||
|
status: { not: "DROPPED" },
|
||||||
|
user: { notifyNewEpisode: true, isActive: true },
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
for (const episode of episodes) {
|
||||||
|
for (const watcher of watchers) {
|
||||||
|
const result = await notifyUser(watcher.userId, {
|
||||||
|
subject: `Neue Episode: ${titleName}`,
|
||||||
|
body: `${episodeCode(episode.seasonNumber, episode.episodeNumber)} „${episode.name}"${
|
||||||
|
episode.airDate
|
||||||
|
? ` läuft am ${episode.airDate.toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
})}.`
|
||||||
|
: " ist angekündigt."
|
||||||
|
}`,
|
||||||
|
path: `/titel/${titleId}`,
|
||||||
|
dedupeKey: `episode:${episode.id}`,
|
||||||
|
});
|
||||||
|
sent += result.sent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { found: episodes.length, sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldet Watchlist-Titel, die neu auf einem Dienst verfügbar sind. */
|
||||||
|
async function notifyAvailability(titleId: string, titleName: string): Promise<number> {
|
||||||
|
const since = new Date(Date.now() - 2 * 86_400_000);
|
||||||
|
|
||||||
|
const providers = await prisma.titleProvider.findMany({
|
||||||
|
where: { titleId, kind: { in: ["FLATRATE", "FREE", "ADS"] }, firstSeenAt: { gte: since } },
|
||||||
|
include: { provider: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (providers.length === 0) return 0;
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
for (const row of providers) {
|
||||||
|
const watchers = await prisma.libraryEntry.findMany({
|
||||||
|
where: {
|
||||||
|
titleId,
|
||||||
|
deletedAt: null,
|
||||||
|
status: "WATCHLIST",
|
||||||
|
user: { notifyAvailability: true, isActive: true, region: row.region },
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const watcher of watchers) {
|
||||||
|
const result = await notifyUser(watcher.userId, {
|
||||||
|
subject: `Jetzt verfügbar: ${titleName}`,
|
||||||
|
body: `„${titleName}" von deiner Watchlist läuft jetzt bei ${row.provider.name}.`,
|
||||||
|
path: `/titel/${titleId}`,
|
||||||
|
dedupeKey: `available:${titleId}:${row.providerId}:${row.region}`,
|
||||||
|
});
|
||||||
|
sent += result.sent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Räumt den Papierkorb und abgelaufene Sitzungen. */
|
||||||
|
export async function purgeExpired(): Promise<{ entries: number; sessions: number }> {
|
||||||
|
const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 86_400_000);
|
||||||
|
|
||||||
|
const expired = await prisma.libraryEntry.findMany({
|
||||||
|
where: { deletedAt: { lt: cutoff } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (expired.length > 0) {
|
||||||
|
const ids = expired.map((entry) => entry.id);
|
||||||
|
await prisma.watch.deleteMany({ where: { entryId: { in: ids } } });
|
||||||
|
await prisma.libraryEntry.deleteMany({ where: { id: { in: ids } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = await prisma.session.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||||
|
|
||||||
|
return { entries: expired.length, sessions: sessions.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der vollständige Lauf. */
|
||||||
|
export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<SyncStats> {
|
||||||
|
const run = await prisma.syncRun.create({ data: { kind: "sync" }, select: { id: true } });
|
||||||
|
|
||||||
|
const stats: SyncStats = {
|
||||||
|
titlesChecked: 0,
|
||||||
|
titlesUpdated: 0,
|
||||||
|
newEpisodes: 0,
|
||||||
|
notificationsSent: 0,
|
||||||
|
entriesPurged: 0,
|
||||||
|
sessionsPurged: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (env.tmdb.isConfigured) {
|
||||||
|
const titles = await titlesDueForSync(limit);
|
||||||
|
|
||||||
|
for (const title of titles) {
|
||||||
|
if (!title.tmdbId) continue;
|
||||||
|
stats.titlesChecked += 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (title.type === "SERIES") {
|
||||||
|
const before = await prisma.episode.findMany({
|
||||||
|
where: { titleId: title.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const known = new Set(before.map((episode) => episode.id));
|
||||||
|
|
||||||
|
await importSeries(title.tmdbId, { region: env.tmdb.region });
|
||||||
|
stats.titlesUpdated += 1;
|
||||||
|
|
||||||
|
const result = await notifyNewEpisodes(title.id, title.title, known);
|
||||||
|
stats.newEpisodes += result.found;
|
||||||
|
stats.notificationsSent += result.sent;
|
||||||
|
} else {
|
||||||
|
await importMovie(title.tmdbId, env.tmdb.region);
|
||||||
|
stats.titlesUpdated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.notificationsSent += await notifyAvailability(title.id, title.title);
|
||||||
|
} catch (error) {
|
||||||
|
stats.errors.push(
|
||||||
|
`${title.title}: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stats.errors.push("Kein TMDB-Schlüssel hinterlegt — der Abgleich wurde übersprungen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const purged = await purgeExpired();
|
||||||
|
stats.entriesPurged = purged.entries;
|
||||||
|
stats.sessionsPurged = purged.sessions;
|
||||||
|
|
||||||
|
await prisma.syncRun.update({
|
||||||
|
where: { id: run.id },
|
||||||
|
data: {
|
||||||
|
finishedAt: new Date(),
|
||||||
|
ok: stats.errors.length === 0,
|
||||||
|
message:
|
||||||
|
stats.errors.length === 0
|
||||||
|
? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden`
|
||||||
|
: `${stats.errors.length} Fehler`,
|
||||||
|
stats: stats as unknown as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
stats.errors.push(error instanceof Error ? error.message : "unbekannter Fehler");
|
||||||
|
await prisma.syncRun.update({
|
||||||
|
where: { id: run.id },
|
||||||
|
data: {
|
||||||
|
finishedAt: new Date(),
|
||||||
|
ok: false,
|
||||||
|
message: stats.errors.join(" | ").slice(0, 500),
|
||||||
|
stats: stats as unknown as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zeitsteuerung
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function msUntilNextRun(hour: number): number {
|
||||||
|
const next = new Date();
|
||||||
|
next.setHours(hour, 5, 0, 0);
|
||||||
|
if (next.getTime() <= Date.now()) next.setDate(next.getDate() + 1);
|
||||||
|
return next.getTime() - Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet den eingebauten Zeitplan.
|
||||||
|
*
|
||||||
|
* Bewusst ein einfacher Timer statt einer Cron-Bibliothek: Flimmer braucht
|
||||||
|
* genau einen täglichen Lauf. Wer mehr will, stößt `/api/cron/sync` von außen
|
||||||
|
* an und lässt `ENABLE_SCHEDULER` aus.
|
||||||
|
*/
|
||||||
|
export function startScheduler(): void {
|
||||||
|
if (timer) return;
|
||||||
|
if (!env.scheduler.enabled) return;
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
const delay = msUntilNextRun(env.scheduler.hour);
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const stats = await runSync();
|
||||||
|
console.log(
|
||||||
|
`[Flimmer] Abgleich fertig: ${stats.titlesUpdated} Titel, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Flimmer] Abgleich fehlgeschlagen:", error);
|
||||||
|
}
|
||||||
|
schedule();
|
||||||
|
}, delay);
|
||||||
|
// Der Timer darf den Prozess nicht am Beenden hindern.
|
||||||
|
timer.unref?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
schedule();
|
||||||
|
console.log(
|
||||||
|
`[Flimmer] Täglicher Abgleich eingerichtet für ${String(env.scheduler.hour).padStart(2, "0")}:05 Uhr.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ import { env } from "./env";
|
|||||||
* Zeichen einzeln nach draußen trägt.
|
* Zeichen einzeln nach draußen trägt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const BASE = "https://api.themoviedb.org/3";
|
const BASE = env.tmdb.baseUrl;
|
||||||
export const IMAGE_BASE = "https://image.tmdb.org/t/p";
|
export const IMAGE_BASE = env.tmdb.imageBaseUrl;
|
||||||
|
|
||||||
export class TmdbNotConfiguredError extends Error {
|
export class TmdbNotConfiguredError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { ensureTitle } from "./catalog";
|
||||||
|
import { addToLibrary } from "./library";
|
||||||
|
import { episodeCode } from "./labels";
|
||||||
|
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daten hinein und hinaus.
|
||||||
|
*
|
||||||
|
* Hinein: Trakt.tv-Export (JSON) und CSV, wie es Serienjunkies, TV Time und
|
||||||
|
* Tabellenkalkulationen ausspucken. Hinaus: dieselben Daten als CSV oder JSON.
|
||||||
|
*
|
||||||
|
* Grundhaltung beim Import: **nichts überschreiben**. Was schon in der
|
||||||
|
* Bibliothek liegt, bleibt wie es ist; es kommt nur dazu, was fehlt. Ein
|
||||||
|
* Import soll niemand versehentlich seinen Fortschritt kosten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ImportSummary = {
|
||||||
|
processed: number;
|
||||||
|
added: number;
|
||||||
|
skipped: number;
|
||||||
|
watchesAdded: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_ALIASES: Record<string, EntryStatus> = {
|
||||||
|
watching: "WATCHING",
|
||||||
|
"schaue gerade": "WATCHING",
|
||||||
|
laufend: "WATCHING",
|
||||||
|
paused: "PAUSED",
|
||||||
|
pausiert: "PAUSED",
|
||||||
|
hold: "PAUSED",
|
||||||
|
completed: "COMPLETED",
|
||||||
|
abgeschlossen: "COMPLETED",
|
||||||
|
watched: "COMPLETED",
|
||||||
|
gesehen: "COMPLETED",
|
||||||
|
dropped: "DROPPED",
|
||||||
|
abgebrochen: "DROPPED",
|
||||||
|
watchlist: "WATCHLIST",
|
||||||
|
plantowatch: "WATCHLIST",
|
||||||
|
merkliste: "WATCHLIST",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseStatus(value: string | undefined): EntryStatus {
|
||||||
|
if (!value) return "WATCHLIST";
|
||||||
|
return STATUS_ALIASES[value.trim().toLocaleLowerCase("de-DE")] ?? "WATCHLIST";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseType(value: string | undefined): TitleType | null {
|
||||||
|
const normalized = value?.trim().toLocaleLowerCase("de-DE");
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (["serie", "series", "show", "tv", "tvshow"].includes(normalized)) return "SERIES";
|
||||||
|
if (["film", "movie", "kinofilm"].includes(normalized)) return "MOVIE";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CSV
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimaler CSV-Leser mit Unterstützung für Anführungszeichen und
|
||||||
|
* eingebettete Zeilenumbrüche. Trennzeichen wird erraten (Komma oder
|
||||||
|
* Semikolon) — deutsche Tabellenprogramme nehmen gern das Semikolon.
|
||||||
|
*/
|
||||||
|
export function parseCsv(text: string): string[][] {
|
||||||
|
const clean = text.replace(/^/, "");
|
||||||
|
const firstLine = clean.split(/\r?\n/, 1)[0] ?? "";
|
||||||
|
const delimiter = (firstLine.match(/;/g)?.length ?? 0) > (firstLine.match(/,/g)?.length ?? 0) ? ";" : ",";
|
||||||
|
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = "";
|
||||||
|
let quoted = false;
|
||||||
|
|
||||||
|
for (let index = 0; index < clean.length; index += 1) {
|
||||||
|
const char = clean[index];
|
||||||
|
|
||||||
|
if (quoted) {
|
||||||
|
if (char === '"') {
|
||||||
|
if (clean[index + 1] === '"') {
|
||||||
|
field += '"';
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
quoted = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
field += char;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
quoted = true;
|
||||||
|
} else if (char === delimiter) {
|
||||||
|
row.push(field);
|
||||||
|
field = "";
|
||||||
|
} else if (char === "\n") {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
field = "";
|
||||||
|
} else if (char !== "\r") {
|
||||||
|
field += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.length > 0 || row.length > 0) {
|
||||||
|
row.push(field);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.filter((entry) => entry.some((cell) => cell.trim().length > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSV_HEADERS: Record<string, string> = {
|
||||||
|
titel: "title",
|
||||||
|
title: "title",
|
||||||
|
name: "title",
|
||||||
|
show: "title",
|
||||||
|
typ: "type",
|
||||||
|
type: "type",
|
||||||
|
art: "type",
|
||||||
|
status: "status",
|
||||||
|
bewertung: "rating",
|
||||||
|
rating: "rating",
|
||||||
|
score: "rating",
|
||||||
|
tmdb: "tmdbId",
|
||||||
|
tmdb_id: "tmdbId",
|
||||||
|
tmdbid: "tmdbId",
|
||||||
|
jahr: "year",
|
||||||
|
year: "year",
|
||||||
|
notiz: "notes",
|
||||||
|
notes: "notes",
|
||||||
|
kommentar: "notes",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV-Import.
|
||||||
|
*
|
||||||
|
* Erwartet eine Kopfzeile. Gebraucht wird nur eine Spalte mit dem Titel;
|
||||||
|
* Typ, Status, Bewertung, TMDB-ID und Notiz werden übernommen, wenn vorhanden.
|
||||||
|
*/
|
||||||
|
export async function importCsv(userId: string, text: string): Promise<ImportSummary> {
|
||||||
|
const summary: ImportSummary = { processed: 0, added: 0, skipped: 0, watchesAdded: 0, errors: [] };
|
||||||
|
const rows = parseCsv(text);
|
||||||
|
const header = rows.shift();
|
||||||
|
|
||||||
|
if (!header) {
|
||||||
|
summary.errors.push("Die Datei ist leer.");
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = header.map(
|
||||||
|
(cell) => CSV_HEADERS[cell.trim().toLocaleLowerCase("de-DE").replace(/\s+/g, "_")] ?? "",
|
||||||
|
);
|
||||||
|
if (!columns.includes("title")) {
|
||||||
|
summary.errors.push(
|
||||||
|
'Es fehlt eine Titelspalte. Die Kopfzeile braucht eine Spalte „Titel" (oder „title", „name", „show").',
|
||||||
|
);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
summary.processed += 1;
|
||||||
|
const record: Record<string, string> = {};
|
||||||
|
columns.forEach((key, index) => {
|
||||||
|
if (key) record[key] = (row[index] ?? "").trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = record.title;
|
||||||
|
if (!title) {
|
||||||
|
summary.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await importOne(userId, {
|
||||||
|
title,
|
||||||
|
type: parseType(record.type),
|
||||||
|
tmdbId: record.tmdbId ? Number.parseInt(record.tmdbId, 10) : null,
|
||||||
|
status: parseStatus(record.status),
|
||||||
|
rating: record.rating ? Number.parseInt(record.rating, 10) : null,
|
||||||
|
year: record.year ? Number.parseInt(record.year, 10) : null,
|
||||||
|
notes: record.notes || null,
|
||||||
|
});
|
||||||
|
if (result === "added") summary.added += 1;
|
||||||
|
else summary.skipped += 1;
|
||||||
|
} catch (error) {
|
||||||
|
summary.errors.push(`${title}: ${error instanceof Error ? error.message : "Fehler"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trakt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type TraktEntry = {
|
||||||
|
type?: string;
|
||||||
|
show?: { title?: string; year?: number; ids?: { tmdb?: number } };
|
||||||
|
movie?: { title?: string; year?: number; ids?: { tmdb?: number } };
|
||||||
|
title?: string;
|
||||||
|
year?: number;
|
||||||
|
ids?: { tmdb?: number };
|
||||||
|
rating?: number;
|
||||||
|
listed_at?: string;
|
||||||
|
last_watched_at?: string;
|
||||||
|
watched_at?: string;
|
||||||
|
plays?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trakt.tv-Export.
|
||||||
|
*
|
||||||
|
* Trakt gibt je nach Ansicht unterschiedliche Formen aus (Watchlist,
|
||||||
|
* History, Ratings, Collection). Gemeinsam ist ihnen ein Array mit Einträgen,
|
||||||
|
* die entweder `show` oder `movie` tragen — genau darauf stützt sich der
|
||||||
|
* Import, statt ein bestimmtes Exportformat zu verlangen.
|
||||||
|
*/
|
||||||
|
export async function importTrakt(userId: string, json: string): Promise<ImportSummary> {
|
||||||
|
const summary: ImportSummary = { processed: 0, added: 0, skipped: 0, watchesAdded: 0, errors: [] };
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
summary.errors.push("Die Datei ist kein gültiges JSON.");
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Array direkt, oder ein Objekt mit Listen darin.
|
||||||
|
const entries: TraktEntry[] = Array.isArray(parsed)
|
||||||
|
? (parsed as TraktEntry[])
|
||||||
|
: typeof parsed === "object" && parsed !== null
|
||||||
|
? Object.values(parsed as Record<string, unknown>)
|
||||||
|
.filter(Array.isArray)
|
||||||
|
.flat() as TraktEntry[]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
summary.errors.push("In der Datei stehen keine Einträge, die Flimmer versteht.");
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
summary.processed += 1;
|
||||||
|
|
||||||
|
const source = entry.show ?? entry.movie ?? entry;
|
||||||
|
const title = source.title;
|
||||||
|
if (!title) {
|
||||||
|
summary.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type: TitleType | null = entry.show
|
||||||
|
? "SERIES"
|
||||||
|
: entry.movie
|
||||||
|
? "MOVIE"
|
||||||
|
: parseType(entry.type);
|
||||||
|
|
||||||
|
// Was schon gesehen wurde, gilt als abgeschlossen; alles andere landet
|
||||||
|
// auf der Watchlist. Genauer geht es aus einem Export nicht hervor.
|
||||||
|
const watched = Boolean(entry.watched_at ?? entry.last_watched_at ?? entry.plays);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await importOne(userId, {
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
tmdbId: source.ids?.tmdb ?? null,
|
||||||
|
status: watched ? "COMPLETED" : "WATCHLIST",
|
||||||
|
rating: typeof entry.rating === "number" ? Math.round(entry.rating) : null,
|
||||||
|
year: source.year ?? null,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
if (result === "added") summary.added += 1;
|
||||||
|
else summary.skipped += 1;
|
||||||
|
} catch (error) {
|
||||||
|
summary.errors.push(`${title}: ${error instanceof Error ? error.message : "Fehler"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gemeinsamer Kern
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ImportRecord = {
|
||||||
|
title: string;
|
||||||
|
type: TitleType | null;
|
||||||
|
tmdbId: number | null;
|
||||||
|
status: EntryStatus;
|
||||||
|
rating: number | null;
|
||||||
|
year: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt einen einzelnen Eintrag auf.
|
||||||
|
*
|
||||||
|
* Mit TMDB-ID wird der Titel bei TMDB geholt; ohne wird im lokalen Katalog
|
||||||
|
* nach dem Namen gesucht und andernfalls ein manueller Eintrag angelegt.
|
||||||
|
* Das ist bewusst so: ein Import soll auch ohne TMDB-Schlüssel durchlaufen.
|
||||||
|
*/
|
||||||
|
async function importOne(userId: string, record: ImportRecord): Promise<"added" | "skipped"> {
|
||||||
|
let titleId: string | null = null;
|
||||||
|
|
||||||
|
if (record.tmdbId && record.type && env.tmdb.isConfigured) {
|
||||||
|
try {
|
||||||
|
const title = await ensureTitle(record.type, record.tmdbId, { region: env.tmdb.region });
|
||||||
|
titleId = title.id;
|
||||||
|
} catch {
|
||||||
|
// TMDB nicht erreichbar oder ID unbekannt — unten weiterversuchen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!titleId) {
|
||||||
|
const existing = await prisma.title.findFirst({
|
||||||
|
where: {
|
||||||
|
title: { equals: record.title, mode: "insensitive" },
|
||||||
|
...(record.type ? { type: record.type } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
titleId = existing?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!titleId) {
|
||||||
|
const created = await prisma.title.create({
|
||||||
|
data: {
|
||||||
|
type: record.type ?? "MOVIE",
|
||||||
|
title: record.title,
|
||||||
|
releaseDate: record.year ? new Date(Date.UTC(record.year, 0, 1)) : null,
|
||||||
|
status: "Importiert",
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
titleId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntry = await prisma.libraryEntry.findUnique({
|
||||||
|
where: { userId_titleId: { userId, titleId } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existingEntry) return "skipped";
|
||||||
|
|
||||||
|
const entry = await addToLibrary(userId, titleId, record.status);
|
||||||
|
|
||||||
|
if (record.rating || record.notes) {
|
||||||
|
await prisma.libraryEntry.update({
|
||||||
|
where: { id: entry.id },
|
||||||
|
data: {
|
||||||
|
rating: record.rating && record.rating >= 1 && record.rating <= 10 ? record.rating : null,
|
||||||
|
notes: record.notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return "added";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function csvCell(value: unknown): string {
|
||||||
|
const text = value === null || value === undefined ? "" : String(value);
|
||||||
|
return /[",;\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bibliothek als CSV — ein Titel je Zeile. */
|
||||||
|
export async function exportCsv(userId: string): Promise<string> {
|
||||||
|
const entries = await prisma.libraryEntry.findMany({
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
orderBy: { title: { title: "asc" } },
|
||||||
|
include: {
|
||||||
|
title: true,
|
||||||
|
tags: { include: { tag: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
"Titel",
|
||||||
|
"Typ",
|
||||||
|
"Jahr",
|
||||||
|
"TMDB",
|
||||||
|
"Status",
|
||||||
|
"Bewertung",
|
||||||
|
"Durchlaeufe",
|
||||||
|
"WoGeschaut",
|
||||||
|
"Tags",
|
||||||
|
"Notiz",
|
||||||
|
"Hinzugefuegt",
|
||||||
|
"Abgeschlossen",
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = entries.map((entry) =>
|
||||||
|
[
|
||||||
|
entry.title.title,
|
||||||
|
entry.title.type === "SERIES" ? "Serie" : "Film",
|
||||||
|
entry.title.releaseDate?.getFullYear() ?? "",
|
||||||
|
entry.title.tmdbId ?? "",
|
||||||
|
entry.status,
|
||||||
|
entry.rating ?? "",
|
||||||
|
entry.currentRun + 1,
|
||||||
|
entry.whereToWatch ?? "",
|
||||||
|
entry.tags.map((link) => link.tag.name).join(" "),
|
||||||
|
entry.notes?.replace(/\r?\n/g, " ") ?? "",
|
||||||
|
entry.createdAt.toISOString().slice(0, 10),
|
||||||
|
entry.finishedAt?.toISOString().slice(0, 10) ?? "",
|
||||||
|
]
|
||||||
|
.map(csvCell)
|
||||||
|
.join(","),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [header.join(","), ...rows].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vollständiger Export als JSON, inklusive Sichtungen und Universen. */
|
||||||
|
export async function exportJson(userId: string): Promise<object> {
|
||||||
|
const [user, entries, watches, universes] = await Promise.all([
|
||||||
|
prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { email: true, name: true, region: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
prisma.libraryEntry.findMany({
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
title: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
tmdbId: true,
|
||||||
|
imdbId: true,
|
||||||
|
releaseDate: true,
|
||||||
|
genres: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: { include: { tag: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.watch.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { watchedAt: "asc" },
|
||||||
|
include: {
|
||||||
|
title: { select: { title: true, tmdbId: true, type: true } },
|
||||||
|
episode: { select: { seasonNumber: true, episodeNumber: true, name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.universe.findMany({
|
||||||
|
where: { ownerId: userId, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
items: { include: { title: { select: { title: true, tmdbId: true, type: true } } } },
|
||||||
|
orders: { include: { items: { orderBy: { position: "asc" } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
format: "flimmer-export",
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
user,
|
||||||
|
library: entries.map((entry) => ({
|
||||||
|
title: entry.title.title,
|
||||||
|
type: entry.title.type,
|
||||||
|
tmdbId: entry.title.tmdbId,
|
||||||
|
imdbId: entry.title.imdbId,
|
||||||
|
year: entry.title.releaseDate?.getFullYear() ?? null,
|
||||||
|
genres: entry.title.genres,
|
||||||
|
status: entry.status,
|
||||||
|
rating: entry.rating,
|
||||||
|
notes: entry.notes,
|
||||||
|
whereToWatch: entry.whereToWatch,
|
||||||
|
favorite: entry.favorite,
|
||||||
|
currentRun: entry.currentRun,
|
||||||
|
tags: entry.tags.map((link) => link.tag.name),
|
||||||
|
startedAt: entry.startedAt,
|
||||||
|
finishedAt: entry.finishedAt,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
})),
|
||||||
|
watches: watches.map((watch) => ({
|
||||||
|
title: watch.title.title,
|
||||||
|
tmdbId: watch.title.tmdbId,
|
||||||
|
type: watch.title.type,
|
||||||
|
episode: watch.episode
|
||||||
|
? episodeCode(watch.episode.seasonNumber, watch.episode.episodeNumber)
|
||||||
|
: null,
|
||||||
|
episodeName: watch.episode?.name ?? null,
|
||||||
|
run: watch.run,
|
||||||
|
watchedAt: watch.watchedAt,
|
||||||
|
minutes: watch.minutes,
|
||||||
|
})),
|
||||||
|
universes: universes.map((universe) => ({
|
||||||
|
name: universe.name,
|
||||||
|
description: universe.description,
|
||||||
|
titles: universe.items.map((item) => ({
|
||||||
|
title: item.title.title,
|
||||||
|
tmdbId: item.title.tmdbId,
|
||||||
|
type: item.title.type,
|
||||||
|
})),
|
||||||
|
orders: universe.orders.map((order) => ({
|
||||||
|
name: order.name,
|
||||||
|
kind: order.kind,
|
||||||
|
positions: order.items.map((entry) => entry.position),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import "./setup";
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { after, before, beforeEach } from "node:test";
|
||||||
|
|
||||||
|
import { installTmdbStub, MOVIE_ID, SERIES_ID } from "./fixtures/tmdb";
|
||||||
|
import { prisma } from "../src/lib/db";
|
||||||
|
import { importMovie, importSeries } from "../src/lib/catalog";
|
||||||
|
import { addToLibrary, markMovieWatched, moveToTrash } from "../src/lib/library";
|
||||||
|
import { exportCsv, exportJson, importCsv, importTrakt, parseCsv } from "../src/lib/transfer";
|
||||||
|
import { purgeExpired, TRASH_RETENTION_DAYS } from "../src/lib/sync";
|
||||||
|
import { notifyUser } from "../src/lib/notify";
|
||||||
|
|
||||||
|
/** Prüft Import, Export, das Aufräumen des Papierkorbs und den Doppelversandschutz. */
|
||||||
|
|
||||||
|
const stub = installTmdbStub();
|
||||||
|
const TEST_EMAIL = "test-transfer@flimmer.invalid";
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
let movieTitleId: string;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
|
||||||
|
await prisma.title.deleteMany({ where: { status: { in: ["Importiert", "Manuell angelegt"] } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await cleanup();
|
||||||
|
userId = (
|
||||||
|
await prisma.user.create({ data: { email: TEST_EMAIL, name: "Transfer" }, select: { id: true } })
|
||||||
|
).id;
|
||||||
|
|
||||||
|
await importSeries(SERIES_ID, { region: "DE" });
|
||||||
|
movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.watch.deleteMany({ where: { userId } });
|
||||||
|
await prisma.libraryEntry.deleteMany({ where: { userId } });
|
||||||
|
await prisma.notificationLog.deleteMany({ where: { userId } });
|
||||||
|
await prisma.title.deleteMany({ where: { status: { in: ["Importiert", "Manuell angelegt"] } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await cleanup();
|
||||||
|
stub.restore();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CSV wird mit Komma und mit Semikolon gelesen", () => {
|
||||||
|
const comma = parseCsv('Titel,Typ\nNordlicht,Serie\n"Ein, Titel",Film\n');
|
||||||
|
assert.deepEqual(comma, [
|
||||||
|
["Titel", "Typ"],
|
||||||
|
["Nordlicht", "Serie"],
|
||||||
|
["Ein, Titel", "Film"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const semicolon = parseCsv("Titel;Typ\nNordlicht;Serie\n");
|
||||||
|
assert.deepEqual(semicolon, [
|
||||||
|
["Titel", "Typ"],
|
||||||
|
["Nordlicht", "Serie"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const quoted = parseCsv('Titel,Notiz\nPfand,"Zeile eins\nZeile zwei"\n');
|
||||||
|
assert.equal(quoted[1]?.[1], "Zeile eins\nZeile zwei", "Zeilenumbrüche in Anführungszeichen");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CSV-Import legt an, ordnet zu und überschreibt nichts", async () => {
|
||||||
|
const csv = [
|
||||||
|
"Titel;Typ;Status;Bewertung;Notiz",
|
||||||
|
"Die Stille zwischen den Bildern;Film;Abgeschlossen;8;Sehr gut",
|
||||||
|
"Ein Film, den es nicht gibt;Film;Watchlist;;",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const first = await importCsv(userId, csv);
|
||||||
|
assert.equal(first.processed, 2);
|
||||||
|
assert.equal(first.added, 2);
|
||||||
|
assert.deepEqual(first.errors, []);
|
||||||
|
|
||||||
|
// Der bekannte Titel wurde einem vorhandenen Katalogeintrag zugeordnet,
|
||||||
|
// statt ein zweites Mal angelegt zu werden.
|
||||||
|
const known = await prisma.libraryEntry.findFirst({
|
||||||
|
where: { userId, title: { title: "Die Stille zwischen den Bildern" } },
|
||||||
|
select: { rating: true, status: true, notes: true, title: { select: { tmdbId: true } } },
|
||||||
|
});
|
||||||
|
assert.ok(known, "der vorhandene Katalogtitel wurde erkannt");
|
||||||
|
assert.ok(known.title.tmdbId, "es wurde kein zweiter, leerer Titel angelegt");
|
||||||
|
assert.equal(known.status, "COMPLETED");
|
||||||
|
assert.equal(known.rating, 8);
|
||||||
|
assert.equal(known.notes, "Sehr gut");
|
||||||
|
|
||||||
|
// Der unbekannte wurde als manueller Titel angelegt.
|
||||||
|
const created = await prisma.title.findFirst({
|
||||||
|
where: { title: "Ein Film, den es nicht gibt" },
|
||||||
|
select: { tmdbId: true },
|
||||||
|
});
|
||||||
|
assert.ok(created);
|
||||||
|
assert.equal(created.tmdbId, null, "manuell angelegte Titel haben keine TMDB-Kennung");
|
||||||
|
|
||||||
|
// Zweiter Durchlauf ändert nichts.
|
||||||
|
const second = await importCsv(userId, csv);
|
||||||
|
assert.equal(second.added, 0);
|
||||||
|
assert.equal(second.skipped, 2, "vorhandene Einträge bleiben unangetastet");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("CSV ohne Titelspalte wird abgelehnt", async () => {
|
||||||
|
const result = await importCsv(userId, "Jahr;Status\n2024;Watchlist\n");
|
||||||
|
assert.equal(result.added, 0);
|
||||||
|
assert.match(result.errors[0] ?? "", /Titelspalte/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Trakt-Export wird verstanden", async () => {
|
||||||
|
const trakt = JSON.stringify([
|
||||||
|
{
|
||||||
|
show: { title: "Nordlicht", year: 2023, ids: { tmdb: 999001 } },
|
||||||
|
last_watched_at: "2025-04-01T20:00:00.000Z",
|
||||||
|
plays: 8,
|
||||||
|
},
|
||||||
|
{ movie: { title: "Hochwasser", year: 2023, ids: { tmdb: 9100015 } }, rating: 9 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await importTrakt(userId, trakt);
|
||||||
|
assert.equal(result.processed, 2);
|
||||||
|
assert.equal(result.added, 2);
|
||||||
|
|
||||||
|
const entries = await prisma.libraryEntry.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { title: { select: { title: true, type: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const series = entries.find((entry) => entry.title.title === "Nordlicht");
|
||||||
|
assert.equal(series?.title.type, "SERIES");
|
||||||
|
assert.equal(series?.status, "COMPLETED", "was gespielt wurde, gilt als gesehen");
|
||||||
|
|
||||||
|
const movie = entries.find((entry) => entry.title.title === "Hochwasser");
|
||||||
|
assert.equal(movie?.title.type, "MOVIE");
|
||||||
|
assert.equal(movie?.status, "WATCHLIST");
|
||||||
|
assert.equal(movie?.rating, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Trakt-Datei ohne brauchbaren Inhalt meldet das", async () => {
|
||||||
|
const kaputt = await importTrakt(userId, "kein json");
|
||||||
|
assert.match(kaputt.errors[0] ?? "", /JSON/);
|
||||||
|
|
||||||
|
const leer = await importTrakt(userId, "[]");
|
||||||
|
assert.match(leer.errors[0] ?? "", /keine Einträge/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Export enthält Bibliothek und Sichtungen", async () => {
|
||||||
|
const entry = await addToLibrary(userId, movieTitleId, "WATCHING");
|
||||||
|
await markMovieWatched(userId, entry.id);
|
||||||
|
|
||||||
|
const csv = await exportCsv(userId);
|
||||||
|
const lines = csv.split("\n");
|
||||||
|
assert.match(lines[0] ?? "", /^Titel,Typ,Jahr,TMDB,Status/);
|
||||||
|
assert.equal(lines.length, 2, "Kopfzeile plus ein Eintrag");
|
||||||
|
assert.match(lines[1] ?? "", /Die Stille zwischen den Bildern/);
|
||||||
|
|
||||||
|
const json = (await exportJson(userId)) as {
|
||||||
|
format: string;
|
||||||
|
library: unknown[];
|
||||||
|
watches: { minutes: number }[];
|
||||||
|
};
|
||||||
|
assert.equal(json.format, "flimmer-export");
|
||||||
|
assert.equal(json.library.length, 1);
|
||||||
|
assert.equal(json.watches.length, 1);
|
||||||
|
assert.equal(json.watches[0]?.minutes, 118);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("der Papierkorb wird erst nach der Frist geleert", async () => {
|
||||||
|
const entry = await addToLibrary(userId, movieTitleId, "WATCHING");
|
||||||
|
await markMovieWatched(userId, entry.id);
|
||||||
|
await moveToTrash(userId, entry.id);
|
||||||
|
|
||||||
|
// Frisch im Papierkorb: bleibt liegen.
|
||||||
|
await purgeExpired();
|
||||||
|
assert.ok(await prisma.libraryEntry.findUnique({ where: { id: entry.id } }));
|
||||||
|
|
||||||
|
// Über die Frist hinaus: verschwindet samt Sichtungen.
|
||||||
|
await prisma.libraryEntry.update({
|
||||||
|
where: { id: entry.id },
|
||||||
|
data: { deletedAt: new Date(Date.now() - (TRASH_RETENTION_DAYS + 1) * 86_400_000) },
|
||||||
|
});
|
||||||
|
const purged = await purgeExpired();
|
||||||
|
|
||||||
|
assert.ok(purged.entries >= 1);
|
||||||
|
assert.equal(await prisma.libraryEntry.findUnique({ where: { id: entry.id } }), null);
|
||||||
|
assert.equal(await prisma.watch.count({ where: { userId, entryId: entry.id } }), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("abgelaufene Sitzungen werden mit aufgeräumt", async () => {
|
||||||
|
await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
tokenHash: `abgelaufen-${Date.now()}`,
|
||||||
|
expiresAt: new Date(Date.now() - 86_400_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await purgeExpired();
|
||||||
|
assert.ok(result.sessions >= 1);
|
||||||
|
assert.equal(await prisma.session.count({ where: { userId } }), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dieselbe Nachricht wird nicht zweimal verschickt", async () => {
|
||||||
|
// Ohne Kanal wird nichts verschickt — der Doppelschutz greift trotzdem.
|
||||||
|
const message = {
|
||||||
|
subject: "Neue Episode",
|
||||||
|
body: "S01E01",
|
||||||
|
dedupeKey: "episode:test-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await notifyUser(userId, message);
|
||||||
|
assert.equal(first.sent, 0);
|
||||||
|
assert.equal(first.skipped, 1, "ohne Kanal gibt es nichts zu tun");
|
||||||
|
|
||||||
|
await prisma.notificationChannel.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
kind: "WEBHOOK",
|
||||||
|
name: "Test",
|
||||||
|
config: { webhookUrl: "http://127.0.0.1:9/nicht-erreichbar" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = await notifyUser(userId, message);
|
||||||
|
assert.equal(second.failed, 1, "der Kanal ist nicht erreichbar");
|
||||||
|
|
||||||
|
const third = await notifyUser(userId, message);
|
||||||
|
assert.equal(
|
||||||
|
third.skipped,
|
||||||
|
1,
|
||||||
|
"auch ein Fehlversuch wird protokolliert — sonst versucht es jeder Lauf erneut",
|
||||||
|
);
|
||||||
|
|
||||||
|
const logs = await prisma.notificationLog.count({ where: { userId, dedupeKey: message.dedupeKey } });
|
||||||
|
assert.equal(logs, 1);
|
||||||
|
|
||||||
|
await prisma.notificationChannel.deleteMany({ where: { userId } });
|
||||||
|
});
|
||||||