20 Commits
Author SHA1 Message Date
Florian Wilde 9ee8664233 feat: eigene Sammlung — „hab ich" mit Quelle
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s
Streaming-Verfügbarkeit beantwortet nur die halbe Frage: manche Filme laufen
nirgends mehr, die hat man gekauft und liegen zu Hause — in Jellyfin, Plex,
Emby, auf dem NAS oder als Scheibe.

Bewusst getrennt vom Feld „wo schaue ich das": das eine sagt, wo ein Titel
gerade läuft, und das ändert sich ständig; das andere sagt, was einem gehört,
und das bleibt. Auf der Detailseite steht die Sammlung deshalb vor der
Verfügbarkeit — was man hat, ist die verlässlichere Antwort.

Durchgezogen bis in Filter, Export, CSV-Import und REST-API.
2026-09-01 21:15:53 +02:00
Florian Wilde cd9953c47c chore: ueberfluessiges @types/bcryptjs entfernen
bcryptjs bringt eigene Typdefinitionen mit; das Stub-Paket war nur noch eine
Deprecation-Warnung beim Installieren.
2026-09-01 20:59:31 +02:00
Florian Wilde 18c87e6b2e fix: Schrift mitliefern statt beim Bauen herunterladen
next/font/google laedt Outfit beim Bauen von Google — das macht den Build von
einer Netzverbindung abhaengig und liess ihn im Container haengen. Die
variable Fassung liegt jetzt als Datei im Repository; weder beim Bauen noch
zur Laufzeit geht eine Anfrage nach aussen.

Ausserdem: npm blockiert seit Version 11 Install-Skripte. Fuer prisma und
@prisma/engines sind sie jetzt gezielt erlaubt, damit im Image die
plattformpassende Engine bereitliegt.
2026-09-01 20:56:32 +02:00
Florian Wilde afa3d74248 docs: Changelog und Release-Notes fuer 0.6.1 vorbereiten
CI / pruefen (push) Canceled after 0s
2026-09-01 20:52:52 +02:00
Florian Wilde b3eb946cfa docs: Diagrammfarben in der Design-Dokumentation festhalten 2026-09-01 20:51:11 +02:00
Florian Wilde 29ef710b9b docs: CI-Ablaeufe in der README erwaehnen 2026-09-01 20:50:47 +02:00
Florian Wilde 3db6241e9a chore: ungenutzte Bildkonfiguration entfernen
next/image kommt nirgends zum Einsatz — Bilder laufen ueber den eigenen
Cache-Endpunkt. Damit entfaellt auch das sicherheitsrelevant aussehende
dangerouslyAllowSVG, das ohnehin wirkungslos war.
2026-09-01 20:50:18 +02:00
Florian Wilde d8239441aa ci: Workflows für Gitea Actions
CI prüft bei jedem Push Typen, Tests und den Produktionsbuild — die Tests
laufen gegen eine echte PostgreSQL-Instanz, weil sie Abfragen und
Fortschrittsberechnungen prüfen, nicht Attrappen.

Der Release-Workflow zieht die Notizen aus releases/vX.Y.Z.md, derselben
Datei, die auch von Hand für ein Release taugt. So gibt es eine Quelle für
den Text, und der Ablauf funktioniert mit und ohne CI.
2026-09-01 20:50:17 +02:00
Florian Wilde 96a42594a9 feat: Erscheinungsmeldung für Watchlist-Filme
Der Auftrag nennt sie ausdrücklich, das Profil hatte den Schalter schon —
verschickt wurde bisher nichts.

Betrachtet wird ein Zeitfenster der letzten Tage statt eines Zustandswechsels:
TMDB verschiebt Termine gelegentlich, und ein einmal verpasstes Datum soll
nicht dazu führen, dass die Meldung für immer ausbleibt. Doppelt kommt sie
trotzdem nie.

Die Meldung hängt nicht am TMDB-Abruf, sondern an den Erscheinungsdaten, die
ohnehin im Katalog stehen — sie funktioniert also auch ohne Schlüssel.
2026-09-01 20:50:17 +02:00
Florian Wilde a54e625ca8 docs: oeffentliches Profil in der Anleitung ergaenzen 2026-09-01 20:37:58 +02:00
Florian Wilde ff9d380d42 docs: Changelog, Release-Notes und DEVLOG fuer 0.6.0 2026-09-01 20:25:13 +02:00
Florian Wilde 8d6f1d044a docs: bebilderte Anleitung, API-Dokumentation und Screenshots
docs/ANLEITUNG.md führt einmal durch die ganze Anwendung — von der ersten
Anmeldung über das tägliche Abhaken bis zu Universen, Statistik und
Verwaltung, mit 18 Aufnahmen aus der laufenden Instanz.

Die Bilder entstehen mit npm run screenshots: aufgenommen mit doppelter
Pixeldichte für scharfe Schrift, abgelegt als WebP in halber Kantenlänge —
13 MB werden so zu 0,6 MB.
2026-09-01 20:25:13 +02:00
Florian Wilde cf38bd6854 feat: TMDB-Adresse konfigurierbar und lokaler Ersatz für die Suche
TMDB_BASE_URL hilft Instanzen hinter einem eigenen Proxy — und erlaubt es,
die Suche ohne Schlüssel aus den Beispieldaten zu bedienen. Damit lässt sich
die Oberfläche ausprobieren, bevor man einen Schlüssel besorgt, und die
Screenshots der Anleitung zeigen eine Suche, die tatsächlich etwas findet.
2026-09-01 20:25:12 +02:00
Florian Wilde 0adcccc01a test: Import, Export, Papierkorb und Doppelversandschutz absichern
Neun Tests: CSV mit Komma und Semikolon, Anführungszeichen und Zeilenumbrüchen,
Zuordnung zu vorhandenen Katalogtiteln, Trakt-Formen, Export in beiden
Formaten, Aufbewahrungsfrist des Papierkorbs und der Schutz davor, dieselbe
Meldung zweimal zu verschicken.
2026-09-01 20:25:12 +02:00
Florian Wilde f02e3597b9 feat: PWA-Feinschliff mit Service Worker und Offline-Seite
Drei Strategien: Poster und statische Dateien zuerst aus dem Zwischenspeicher,
Seiten zuerst aus dem Netz, API-Aufrufe gar nicht.

Ein Tracker, der veraltete Stände zeigt, wäre schlimmer als einer, der ehrlich
sagt: gerade keine Verbindung. Schreibende Anfragen laufen nie über den
Service Worker.
2026-09-01 20:25:11 +02:00
Florian Wilde a9314acbb4 feat: Einstellungen, Verwaltung und öffentliches Profil
Nach einem Passwortwechsel fliegen alle anderen Geräte raus; ein gesperrtes
Konto wird sofort abgemeldet. Die letzte Administration lässt sich nicht
entmachten, und sich selbst kann niemand sperren.

Das öffentliche Profil zeigt Zahlen und zuletzt Gesehenes — keine Notizen,
keine Watchlist, keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat.
Es braucht die Freigabe des Nutzers und die des Betreibers.
2026-09-01 20:25:11 +02:00
Florian Wilde bb63827bf7 feat: REST-API unter /api/v1 mit OpenAPI-Beschreibung
Dieselben Endpunkte bedienen Skripte und das eigene Frontend: erst wird ein
Bearer-Token geprüft, sonst das Sitzungscookie.

Sichtungsaktionen geben immer watchIds zurück — damit lässt sich genau diese
Aktion über DELETE zurücknehmen, ohne raten zu müssen, was dazugehörte.

Die OpenAPI-Beschreibung ist handgeschrieben statt aus Zod erzeugt: die
Schnittstelle ist überschaubar, und ein Generator brächte eine weitere
Abhängigkeit für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
2026-09-01 20:24:48 +02:00
Florian Wilde b25f0473fd feat: Import aus Trakt und CSV, Export als CSV und 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.

Trakt gibt je nach Ansicht unterschiedliche Formen aus. 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.

Der CSV-Leser errät das Trennzeichen: deutsche Tabellenprogramme nehmen gern
das Semikolon.
2026-09-01 20:24:47 +02:00
Florian Wilde 969353ab32 feat: täglicher Abgleich mit TMDB
Frischt laufende Serien auf, meldet neue Episoden und neu verfügbare
Watchlist-Titel, räumt Papierkorb und abgelaufene Sitzungen.

Grundsatz: Der Lauf darf nie an einem einzelnen Titel scheitern. Fehler werden
gezählt und protokolliert, der Lauf macht weiter.

Neue Episoden werden nur gemeldet, wenn sie zeitlich nicht in ferner
Vergangenheit liegen — sonst löste der erste Abgleich einer alten Serie hundert
Meldungen aus.

Der Zeitplan ist ein einfacher Timer statt einer Cron-Bibliothek: Flimmer
braucht genau einen täglichen Lauf. Wer mehr will, stösst /api/cron/sync von
aussen an.
2026-09-01 20:24:47 +02:00
Florian Wilde f49ac17922 feat: Benachrichtigungen über E-Mail, ntfy, Gotify und Webhook
Vier Wege, ein Nachrichtenformat. Der Webhook trägt sowohl content als auch
text im Rumpf — damit bedient derselbe Kanal Discord, Slack und Mattermost,
ohne dass jemand ein Format auswählen muss.

Auch ein Fehlversuch wird protokolliert: sonst versucht es jeder nächste Lauf
erneut und der Kanal wird zugespammt, sobald er wieder erreichbar ist.

Die E-Mail-Vorlage ist schlichtes HTML mit Inline-Stilen — Mailprogramme
können weder externe Stylesheets noch CSS-Variablen. Das Logo steckt als
Inline-SVG drin.
2026-09-01 20:24:45 +02:00
90 changed files with 5886 additions and 64 deletions
+3
View File
@@ -45,6 +45,9 @@ TMDB_ACCESS_TOKEN=
# Sprache der Metadaten und Region für die Streaming-Verfügbarkeit
TMDB_LANGUAGE=de-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 --------------------------------------------------------
# Eingebauter Scheduler (prüft laufende Serien auf neue Episoden, räumt den
+63
View File
@@ -0,0 +1,63 @@
# Prüft jeden Push: Typen, Tests, Produktionsbuild.
#
# Die Tests brauchen eine echte PostgreSQL-Instanz — sie prüfen Abfragen und
# Fortschrittsberechnungen gegen die Datenbank, nicht gegen Attrappen. Einen
# TMDB-Schlüssel brauchen sie nicht: die TMDB-Antworten sind nachgebildet.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
pruefen:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: flimmer
POSTGRES_PASSWORD: flimmer-ci
POSTGRES_DB: flimmer
options: >-
--health-cmd "pg_isready -U flimmer -d flimmer"
--health-interval 5s
--health-timeout 5s
--health-retries 12
ports:
- 5432:5432
env:
DATABASE_URL: postgresql://flimmer:flimmer-ci@localhost:5432/flimmer?schema=public
AUTH_SECRET: ci-geheimnis-0123456789abcdef0123456789abcdef
APP_URL: http://localhost:3000
TZ: Europe/Berlin
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Abhängigkeiten installieren
run: npm ci --no-audit --no-fund
- name: Prisma-Client erzeugen
run: npx prisma generate
- name: Schema einspielen
run: npx prisma migrate deploy
- name: Typen prüfen
run: npm run typecheck
- name: Tests
run: npm test
- name: Produktionsbuild
run: npx next build
+56
View File
@@ -0,0 +1,56 @@
# Legt beim Pushen eines Versions-Tags automatisch das Release an.
#
# Die Notizen kommen aus releases/vX.Y.Z.md — derselben Datei, die auch von
# Hand für ein Release taugt. So gibt es genau eine Quelle für den Text, und
# der Ablauf funktioniert mit und ohne CI.
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
veroeffentlichen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Release-Notizen einsammeln
id: notes
run: |
VERSION="${GITHUB_REF_NAME#v}"
FILE="releases/${GITHUB_REF_NAME}.md"
if [ ! -f "$FILE" ]; then
echo "Es gibt keine $FILE — bitte anlegen, bevor der Tag gepusht wird." >&2
exit 1
fi
# Die Überschrift der Datei ist der Titel; alles darunter der Text.
{
echo "version=$VERSION"
echo "body<<FLIMMER_EOF"
tail -n +2 "$FILE"
echo "FLIMMER_EOF"
} >> "$GITHUB_OUTPUT"
- name: Release anlegen
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
BODY: ${{ steps.notes.outputs.body }}
VERSION: ${{ steps.notes.outputs.version }}
run: |
PAYLOAD=$(jq -n \
--arg tag "$GITHUB_REF_NAME" \
--arg name "$VERSION" \
--arg body "$BODY" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
curl -sSf -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
+1
View File
@@ -42,3 +42,4 @@ Thumbs.db
/blob-report/
.playwright/
src/generated/
.env.composetest
+110 -1
View File
@@ -7,6 +7,113 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
## [0.7.0] — 2026-09-01
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
gestartet, und dabei kamen Fehler ans Licht, die nur der echte Containerstart
zeigt.
### Hinzugefügt
- **Eigene Sammlung**: Jeder Eintrag lässt sich als „hab ich" markieren, mit
Angabe wo — Jellyfin, Plex, Emby, NAS, Blu-ray, DVD oder frei getippt.
Bewusst getrennt vom Feld „wo schaue ich das": Streaming-Verfügbarkeit kommt
und geht, der gekaufte Film auf dem Heimserver bleibt. Gerade die Titel, die
nirgends mehr laufen, will man ja wiederfinden.
- Abzeichen auf der Posterkachel und im Kopf der Detailseite
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit
— was man hat, ist die verlässlichere Antwort auf „wo kann ich das schauen"
- Eigener Filter in der Bibliothek samt Zähler
- In Export (CSV und JSON), CSV-Import und REST-API (`?sammlung=1` beim
Lesen, `owned` und `ownedSource` beim Ändern)
### Behoben
- **Der Container konnte seine Migrationen nicht einspielen.** Das Laufzeitimage
bekam die Prisma-CLI, indem einzelne Ordner aus `node_modules` kopiert wurden
— dabei fehlten transitive Abhängigkeiten von `@prisma/config` (`effect` und
weitere). Die CLI wird jetzt in einer eigenen Build-Stufe vollständig
installiert und liegt im Laufzeitimage unter `/node_modules`, wo sie den
getracten Abhängigkeitsbaum der Anwendung nicht stört.
- **Der Build hing im Container am Schriftabruf.** `next/font/google` lädt
Outfit beim Bauen von Google herunter — das macht den Build von einer
Netzverbindung abhängig und blieb im Container hängen. Die variable Fassung
liegt jetzt als Datei im Repository; weder beim Bauen noch zur Laufzeit geht
eine Anfrage nach außen. Für eine selbst gehostete Anwendung ist das ohnehin
das Richtige.
- **npm blockierte Prismas Install-Skripte** (Voreinstellung seit npm 11),
wodurch im Image die plattformpassende Engine fehlen konnte. Sie sind jetzt
gezielt für `prisma` und `@prisma/engines` erlaubt statt pauschal.
### Hinzugefügt
- **Erscheinungsmeldung für Watchlist-Filme.** Der Schalter dafür stand schon im
Profil, verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der
letzten Tage statt eines Zustandswechsels — TMDB verschiebt Termine
gelegentlich, und ein einmal verpasstes Datum soll die Meldung nicht für
immer verhindern.
- **Gitea-Actions-Workflows**: `ci.yml` prüft bei jedem Push Typen, Tests und
Produktionsbuild gegen eine echte PostgreSQL-Instanz; `release.yml` legt beim
Pushen eines Versions-Tags das Release mit den Notizen aus
`releases/vX.Y.Z.md` an.
- Diagrammfarben und ihre Prüfwerte in `docs/DESIGN.md` festgehalten; das
öffentliche Profil in die Anleitung aufgenommen.
### Entfernt
- Die ungenutzte `images`-Konfiguration in `next.config.ts`. `next/image` kommt
nirgends zum Einsatz — damit entfällt auch das sicherheitsrelevant aussehende
`dangerouslyAllowSVG`, das ohnehin wirkungslos war.
## [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
Übersicht, Kalender und Statistik.
@@ -214,7 +321,9 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
dass weitere Sprachen als zusätzliche Datei dazukommen können.
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.5.0...HEAD
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.0...HEAD
[0.7.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.0
[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.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
+19 -2
View File
@@ -100,8 +100,25 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
## Fortschritt
- ✅ **Etappe 1** (v0.1.0, released) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
- ⏳ **Etappe 2** — TMDB-Anbindung
- ✅ **Etappe 1** (v0.1.0) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
- ✅ **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 -->
+63 -1
View File
@@ -75,6 +75,34 @@ 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)
---
## Nach der Abnahme (v0.7.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 7.1 | 20:25 | 20:50 | 25 min | Docker-Image zum ersten Mal wirklich gebaut und gestartet; zwei Fehler behoben (fehlende Prisma-Abhängigkeiten im Laufzeitimage, Schriftabruf beim Bauen), Gitea-Workflows, Erscheinungsmeldung nachgezogen |
| 7.2 | 20:50 | 21:05 | 15 min | Compose-Stack von null verifiziert: Migration, erstes Konto wird Administrator, Verwaltung erreichbar |
| 7.3 | 21:05 | 21:16 | 11 min | Rückfrage aus dem Betrieb umgesetzt: „hab ich" für die eigene Sammlung (Jellyfin, Plex, NAS, Blu-ray) — Schema, Logik, Oberfläche, Filter, Import/Export, API, Tests, Anleitung |
**Nacharbeiten gesamt: 51 min** (2026-09-01, 20:25 – 21:16)
---
## Gesamtübersicht
| Etappe | Version | Dauer |
@@ -84,5 +112,39 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| 3 — Kern-Tracking | 0.3.0 | 24 min |
| 4 — Universen | 0.4.0 | 14 min |
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
**Bisher gesamt: 105 min**
**Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16)
### 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) |
| ~25 min | Tests — 52 Stück, die unterwegs acht 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.
8. Eine Testerwartung zur Reihenfolge war falsch — nicht der Code.
### Fehler, die erst der echte Containerstart zeigte
1. Dem Laufzeitimage fehlten transitive Abhängigkeiten der Prisma-CLI, die
Migration lief nicht an. Die CLI wird jetzt in einer eigenen Build-Stufe
vollständig installiert.
2. npm blockiert seit Version 11 Install-Skripte; ohne Freigabe fehlte die
plattformpassende Prisma-Engine.
3. `next/font/google` lud die Schrift beim Bauen herunter und ließ den Build
im Container hängen. Die Schrift liegt jetzt im Repository.
+22 -3
View File
@@ -16,6 +16,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# npm blockt Install-Skripte seit Version 11. Welche laufen dürfen, steht im
# Feld "allowScripts" der package.json — für Prisma, damit die zur Plattform
# passende Engine bereitgelegt wird.
RUN npm ci --no-audit --no-fund
# --- Build -------------------------------------------------------------------
@@ -32,6 +35,18 @@ ENV NODE_ENV=production
RUN npx prisma generate && npx next build
# --- Prisma-CLI --------------------------------------------------------------
# Eigene Stufe, damit die CLI samt ihrer transitiven Abhängigkeiten vollständig
# ins Laufzeitimage kommt. Einzelne Pakete aus node_modules herauszupicken ging
# schief: @prisma/config braucht weitere Module, die dabei fehlten.
FROM base AS prisma-cli
WORKDIR /cli
# Die package.json liegt als Datei bei: nur mit dem Feld "allowScripts" lässt
# npm die Install-Skripte von Prisma zu — sonst fehlt im Image die zur
# Plattform passende Engine.
COPY docker/prisma-cli-package.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund prisma@7.10.0
# --- Laufzeit ----------------------------------------------------------------
FROM base AS runner
WORKDIR /app
@@ -48,12 +63,16 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=flimmer:nodejs /app/.next/standalone ./
COPY --from=builder --chown=flimmer:nodejs /app/.next/static ./.next/static
# Prisma-CLI und Schema bleiben im Image, damit der Container beim Start seine
# Schema und Migrationen bleiben im Image, damit der Container beim Start seine
# eigenen Migrationen ausspielen kann (`prisma migrate deploy`).
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.mjs ./prisma.config.mjs
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# Die CLI liegt bewusst unter /node_modules statt in /app/node_modules: dort
# stört sie den getracten Abhängigkeitsbaum der Anwendung nicht, und Node
# findet sie beim Auflösen von "prisma/config" trotzdem — die Suche wandert
# von /app aus nach oben.
COPY --from=prisma-cli /cli/node_modules /node_modules
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh \
+55 -5
View File
@@ -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
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
- **Tracking** — Serien und Filme mit Status (Schaue gerade, Pausiert,
@@ -25,12 +33,20 @@ bleiben auf deinem Server.
Story-Chronologie, Einsteiger-Route) und Gesamtfortschritt.
- **Metadaten von TMDB** — Suche, Poster, Staffeln und Episoden, offizielle
Collections, Streaming-Verfügbarkeit in deiner Region.
- **Eigene Sammlung** — markiere, was du schon hast, und wo: Jellyfin, Plex,
NAS, Blu-ray. Gerade die Titel, die nirgends mehr streamen, findest du so
wieder.
- **Benachrichtigungen** — bei neuen Episoden und Releases, per E-Mail, ntfy,
Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar.
- **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen
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,
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
@@ -165,22 +181,56 @@ npm run dev
| `npm run db:migrate` | Migration erzeugen und anwenden |
| `npm run db:studio` | Prisma Studio |
| `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 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:
```
src/
app/ Routen (App Router), API-Endpunkte
app/ Routen (App Router), API-Endpunkte, Server-Aktionen
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
prisma/ Schema, Migrationen, Seed
prisma/ Schema, Migrationen, Seed samt Beispielkatalog
tests/ Testlauf mit nachgebildeten TMDB-Antworten
public/brand/ Logo in allen Varianten
docker/ Entrypoint des Containers
docs/ Anleitung, Design-Dokumentation
docs/ Anleitung mit Screenshots, API, Design-Dokumentation
```
### Automatisierung
Unter `.gitea/workflows/` liegen zwei Abläufe für Gitea Actions: `ci.yml`
prüft bei jedem Push Typen, Tests und Produktionsbuild gegen eine echte
PostgreSQL-Instanz, `release.yml` legt beim Pushen eines Versions-Tags das
Release an — mit den Notizen aus `releases/vX.Y.Z.md`.
## 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
Next.js (App Router) mit TypeScript, PostgreSQL über Prisma, Tailwind CSS.
+1 -1
View File
@@ -40,7 +40,7 @@ attempt();
'
echo "Flimmer: spiele Migrationen ein ..."
node node_modules/prisma/build/index.js migrate deploy
node /node_modules/prisma/build/index.js migrate deploy
echo "Flimmer: starte Anwendung."
exec "$@"
+9
View File
@@ -0,0 +1,9 @@
{
"name": "flimmer-prisma-cli",
"private": true,
"description": "Nur zum Installieren der Prisma-CLI ins Laufzeitimage. npm blockt Install-Skripte seit Version 11; ohne die Freigabe hier fehlt im Container die plattformpassende Engine. Bei einem Prisma-Update auch die Version in package.json anpassen.",
"allowScripts": {
"prisma@7.10.0": true,
"@prisma/engines@7.10.0": true
}
}
+404
View File
@@ -0,0 +1,404 @@
<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. [Hab ich — die eigene Sammlung](#hab-ich--die-eigene-sammlung)
6. [Die Bibliothek](#die-bibliothek)
7. [Universen](#universen)
8. [Kalender](#kalender)
9. [Statistik](#statistik)
10. [Einstellungen](#einstellungen)
11. [Benachrichtigungen](#benachrichtigungen)
12. [Import und Export](#import-und-export)
13. [API-Schlüssel](#api-schlüssel)
14. [Papierkorb](#papierkorb)
15. [Verwaltung](#verwaltung)
16. [Öffentliches Profil](#öffentliches-profil)
17. [Auf dem Handy](#auf-dem-handy)
18. [Helles Farbschema](#helles-farbschema)
---
## Erster Start
![Anmeldung](screenshots/01-anmeldung.webp)
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
![Übersicht](screenshots/02-uebersicht.webp)
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
![Suche](screenshots/03-suche.webp)
Ü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
![Serie](screenshots/04-serie.webp)
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".
---
## Hab ich — die eigene Sammlung
![Sammlung](screenshots/20-sammlung.webp)
„Wo läuft das?" beantwortet nur die halbe Frage. Manche Filme laufen nirgends
mehr — die hat man gekauft und liegen bei sich zu Hause, in Jellyfin, Plex,
Emby, auf dem NAS oder als Scheibe im Regal.
Dafür gibt es das Häkchen **Hab ich** auf der Detailseite, rechts über dem Feld
„wo schaue ich das". Daneben trägst du ein, *wo* der Titel liegt; das Feld
schlägt die gängigen Möglichkeiten vor, nimmt aber auch alles andere an.
Was das bewirkt:
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit —
was du hast, ist die verlässlichere Antwort.
- Poster in der Bibliothek bekommen ein Abzeichen, und statt des
Streamingdienstes steht dort deine Quelle.
- In der Filterleiste gibt es einen eigenen Knopf **Hab ich** samt Zähler.
- Export und Import führen die Angabe mit (Spalten `HabIch` und `Sammlung`),
ebenso die REST-API.
Bewusst getrennt vom Feld „wo schaue ich das": Das eine sagt, wo ein Titel
gerade läuft — das ändert sich ständig. Das andere sagt, was dir gehört, und
das bleibt.
---
## Die Bibliothek
![Bibliothek](screenshots/05-bibliothek.webp)
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
![Universen](screenshots/06-universen.webp)
Ein Universum bündelt Titel, die zusammengehören — MCU, Star Wars, Wizarding
World — und zwar **Filme und Serien gemeinsam**.
![Universum](screenshots/07-universum.webp)
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
![Kalender](screenshots/09-kalender.webp)
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
![Statistik](screenshots/10-statistik.webp)
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
![Einstellungen](screenshots/11-einstellungen.webp)
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
![Benachrichtigungen](screenshots/12-benachrichtigungen.webp)
*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 und Export](screenshots/14-daten.webp)
**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
![API-Schlüssel](screenshots/13-api.webp)
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
![Papierkorb](screenshots/16-papierkorb.webp)
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
![Verwaltung](screenshots/15-verwaltung.webp)
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.
---
## Öffentliches Profil
![Öffentliches Profil](screenshots/19-profil.webp)
Wer mag, kann eine schlichte Seite unter `/u/<adresse>` freischalten — mit
Schauzeit, Zahlen, zuletzt Gesehenem und den Listen „Die meiste Zeit" und
„Bestbewertet".
Was dort **nicht** steht: Notizen, Watchlist, E-Mail-Adresse, Tags. Die Seite
braucht zwei Freigaben — deine in den Einstellungen und die des Betreibers in
der Verwaltung. Fehlt eine davon, ist die Adresse schlicht nicht erreichbar.
---
## Auf dem Handy
![Auf dem Handy](screenshots/17-mobil-uebersicht.webp)
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
![Helles Farbschema](screenshots/08-hell.webp)
Ü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).
+201
View File
@@ -0,0 +1,201 @@
# 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`, `sammlung`, `limit`,
`offset`. Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
`sammlung=1` liefert nur Titel, die in der eigenen Sammlung liegen.
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`
sowie `owned` (liegt der Titel in der eigenen Sammlung?) und `ownedSource`
(wo genau — Jellyfin, Plex, Blu-ray …).
`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: was ich habe, aber noch nicht gesehen habe
```bash
curl -s -H "Authorization: Bearer $KEY" \
"$HOST/api/v1/bibliothek?sammlung=1&status=WATCHLIST" \
| jq -r '.items[] | "\(.title) — liegt bei: \(.ownedSource // "ohne Angabe")"'
```
## 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
```
+32 -3
View File
@@ -103,6 +103,30 @@ denselben Variablennamen — keine Änderung an Komponenten.
| `.fl-progress` | Fortschrittsbalken im Signaturverlauf |
| `.fl-glimmer` | Mikro-Animation beim Abhaken einer Episode |
## Diagrammfarben
Die Signaturfarben sind für Diagrammflächen auf dunklem Grund zu hell. Für die
beiden Serien der Statistik stehen deshalb abgedunkelte Varianten:
| Serie | Wert | Prüfung |
|---|---|---|
| Episoden | `#c47a18` | Helligkeitsband, Sättigung, Kontrast ≥ 3:1 — in **beiden** Modi |
| Filme | `#d92d78` | dito |
Das schlechteste Paar erreicht ΔE 12,1 unter Deuteranopie und 20,6 bei
normalem Sehen (Ziel: ≥ 8 bzw. ≥ 15). Beide Werte gelten auf hellem wie
dunklem Grund, weshalb es nur **eine** Diagrammpalette gibt statt zweier.
Balken ohne Identitätsbedeutung (Genre-Verteilung) tragen einheitlich
`#c47a18` — die Länge zeigt schon die Größe, die Farbe muss nichts
wiederholen. Streamingdienste tragen ihre Markenfarbe, aber nur, wenn sie auf
hellem *und* dunklem Grund mindestens 3:1 erreicht; sonst fällt der Balken auf
den geprüften Standardton zurück.
Die Aussage hängt nie allein an der Farbe: Legende, Achsenwerte, ein Tooltip
beim Überfahren und eine für Screenreader hinterlegte Tabelle tragen dieselbe
Information.
## Bewegung
Ruhig und kurz: 200–420 ms, `cubic-bezier(0.22, 1, 0.36, 1)`. Keine harten
@@ -111,9 +135,14 @@ verklingt. `prefers-reduced-motion: reduce` schaltet sämtliche Übergänge ab.
## Typografie
**Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Wird beim Bauen
heruntergeladen und mit ausgeliefert; die laufende Instanz fragt nie bei Google
an. Rückfallkette: Inter, dann System-Sans.
**Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Die variable
Fassung (Strichstärke 100–900) liegt als Datei im Repository
(`public/fonts/`), aufgeteilt in Latin und Latin-Extended.
Bewusst nicht `next/font/google`: das lädt die Schrift beim Bauen herunter und
macht den Build von einer Netzverbindung abhängig — im Container blieb er
daran hängen. Mit der mitgelieferten Datei gibt es weder beim Bauen noch zur
Laufzeit einen Abruf nach außen. Rückfallkette: Inter, dann System-Sans.
## Grundhaltung
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+2 -6
View File
@@ -14,12 +14,8 @@ const nextConfig: NextConfig = {
},
poweredByHeader: false,
serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"],
// Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über
// next/image-Remote-Loader — deshalb hier keine remotePatterns nötig.
images: {
dangerouslyAllowSVG: true,
contentDispositionType: "attachment",
},
// Bilder laufen über den eigenen Cache-Endpunkt (/api/image), nicht über
// next/image — deshalb ist hier nichts zu konfigurieren.
async headers() {
return [
{
+2 -14
View File
@@ -1,12 +1,12 @@
{
"name": "flimmer",
"version": "0.1.0",
"version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flimmer",
"version": "0.1.0",
"version": "0.6.1",
"license": "MIT",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
@@ -25,7 +25,6 @@
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/bcryptjs": "3.0.0",
"@types/node": "24.10.1",
"@types/nodemailer": "8.0.1",
"@types/pg": "^8.23.1",
@@ -1945,17 +1944,6 @@
"tailwindcss": "4.3.3"
}
},
"node_modules/@types/bcryptjs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"bcryptjs": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
+8 -4
View File
@@ -1,6 +1,6 @@
{
"name": "flimmer",
"version": "0.5.0",
"version": "0.7.0",
"private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT",
@@ -15,8 +15,9 @@
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"icons": "tsx scripts/generate-icons.ts",
"screenshots": "tsx scripts/screenshots.ts",
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts"
"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",
"tmdb:stub": "node --import tsx scripts/tmdb-stub.mjs"
},
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
@@ -35,7 +36,6 @@
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.3",
"@types/bcryptjs": "3.0.0",
"@types/node": "24.10.1",
"@types/nodemailer": "8.0.1",
"@types/pg": "^8.23.1",
@@ -50,5 +50,9 @@
},
"engines": {
"node": ">=22"
},
"allowScripts": {
"prisma@7.10.0": true,
"@prisma/engines@7.10.0": true
}
}
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "LibraryEntry" ADD COLUMN "owned" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "ownedSource" TEXT;
-- CreateIndex
CREATE INDEX "LibraryEntry_userId_owned_idx" ON "LibraryEntry"("userId", "owned");
+8
View File
@@ -289,6 +289,13 @@ model LibraryEntry {
/// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar
whereToWatch String?
/// Liegt der Titel in der eigenen Sammlung? Unabhängig davon, ob ihn gerade
/// ein Streamingdienst führt — gekaufte Filme laufen irgendwann nirgends mehr.
owned Boolean @default(false)
/// Wo genau: „Jellyfin", „Blu-ray", „NAS", „Plex" …
ownedSource String?
favorite Boolean @default(false)
/// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch …
@@ -309,6 +316,7 @@ model LibraryEntry {
@@unique([userId, titleId])
@@index([userId, status])
@@index([userId, updatedAt])
@@index([userId, owned])
@@index([deletedAt])
}
+10 -3
View File
@@ -244,6 +244,9 @@ type EntryPlan = {
startedDaysAgo?: number;
/** Abstand zwischen zwei Episoden in Tagen. */
spacingDays?: number;
/** Liegt der Titel in der eigenen Sammlung? */
owned?: boolean;
ownedSource?: string;
};
const ENTRY_PLANS: EntryPlan[] = [
@@ -315,8 +318,8 @@ const ENTRY_PLANS: EntryPlan[] = [
},
{ key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] },
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340 },
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320 },
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340, owned: true, ownedSource: "Jellyfin" },
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320, owned: true, ownedSource: "Jellyfin" },
{ key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 },
{ key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] },
{ key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] },
@@ -330,7 +333,7 @@ const ENTRY_PLANS: EntryPlan[] = [
startedDaysAgo: 180,
},
{ key: "stille-bilder-2", status: "WATCHLIST" },
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60 },
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60, owned: true, ownedSource: "Blu-ray" },
{ key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 },
{ key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 },
];
@@ -355,6 +358,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
rating: plan.rating ?? null,
notes: plan.notes ?? null,
favorite: plan.favorite ?? false,
owned: plan.owned ?? false,
ownedSource: plan.ownedSource ?? null,
currentRun,
startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null,
finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null,
@@ -364,6 +369,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
rating: plan.rating ?? null,
notes: plan.notes ?? null,
favorite: plan.favorite ?? false,
owned: plan.owned ?? false,
ownedSource: plan.ownedSource ?? null,
currentRun,
},
});
Binary file not shown.
Binary file not shown.
+108
View File
@@ -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));
}
});
+46
View File
@@ -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.
+59
View File
@@ -0,0 +1,59 @@
# Flimmer 0.7.0
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
gestartet, und dabei kamen Fehler ans Licht, die nur der echte Containerstart
zeigt.
### Hinzugefügt
- **Eigene Sammlung**: Jeder Eintrag lässt sich als „hab ich" markieren, mit
Angabe wo — Jellyfin, Plex, Emby, NAS, Blu-ray, DVD oder frei getippt.
Bewusst getrennt vom Feld „wo schaue ich das": Streaming-Verfügbarkeit kommt
und geht, der gekaufte Film auf dem Heimserver bleibt. Gerade die Titel, die
nirgends mehr laufen, will man ja wiederfinden.
- Abzeichen auf der Posterkachel und im Kopf der Detailseite
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit
— was man hat, ist die verlässlichere Antwort auf „wo kann ich das schauen"
- Eigener Filter in der Bibliothek samt Zähler
- In Export (CSV und JSON), CSV-Import und REST-API (`?sammlung=1` beim
Lesen, `owned` und `ownedSource` beim Ändern)
### Behoben
- **Der Container konnte seine Migrationen nicht einspielen.** Das Laufzeitimage
bekam die Prisma-CLI, indem einzelne Ordner aus `node_modules` kopiert wurden
— dabei fehlten transitive Abhängigkeiten von `@prisma/config` (`effect` und
weitere). Die CLI wird jetzt in einer eigenen Build-Stufe vollständig
installiert und liegt im Laufzeitimage unter `/node_modules`, wo sie den
getracten Abhängigkeitsbaum der Anwendung nicht stört.
- **Der Build hing im Container am Schriftabruf.** `next/font/google` lädt
Outfit beim Bauen von Google herunter — das macht den Build von einer
Netzverbindung abhängig und blieb im Container hängen. Die variable Fassung
liegt jetzt als Datei im Repository; weder beim Bauen noch zur Laufzeit geht
eine Anfrage nach außen. Für eine selbst gehostete Anwendung ist das ohnehin
das Richtige.
- **npm blockierte Prismas Install-Skripte** (Voreinstellung seit npm 11),
wodurch im Image die plattformpassende Engine fehlen konnte. Sie sind jetzt
gezielt für `prisma` und `@prisma/engines` erlaubt statt pauschal.
### Hinzugefügt
- **Erscheinungsmeldung für Watchlist-Filme.** Der Schalter dafür stand schon im
Profil, verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der
letzten Tage statt eines Zustandswechsels — TMDB verschiebt Termine
gelegentlich, und ein einmal verpasstes Datum soll die Meldung nicht für
immer verhindern.
- **Gitea-Actions-Workflows**: `ci.yml` prüft bei jedem Push Typen, Tests und
Produktionsbuild gegen eine echte PostgreSQL-Instanz; `release.yml` legt beim
Pushen eines Versions-Tags das Release mit den Notizen aus
`releases/vX.Y.Z.md` an.
- Diagrammfarben und ihre Prüfwerte in `docs/DESIGN.md` festgehalten; das
öffentliche Profil in die Anleitung aufgenommen.
### Entfernt
- Die ungenutzte `images`-Konfiguration in `next.config.ts`. `next/image` kommt
nirgends zum Einsatz — damit entfällt auch das sicherheitsrelevant aussehende
`dangerouslyAllowSVG`, das ohnehin wirkungslos war.
+34
View File
@@ -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`);
+99
View File
@@ -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();
+98
View File
@@ -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);
});
}
+2
View File
@@ -114,6 +114,8 @@ const detailsSchema = z.object({
notes: z.string().max(5000).nullable().optional(),
whereToWatch: z.string().max(120).nullable().optional(),
favorite: z.boolean().optional(),
owned: z.boolean().optional(),
ownedSource: z.string().max(80).nullable().optional(),
});
export async function updateEntryAction(entryId: string, data: unknown) {
+306
View File
@@ -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]}`,
};
});
}
+38
View File
@@ -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,89 @@
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,
owned: entry.owned,
ownedSource: entry.ownedSource,
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(),
owned: z.boolean().optional(),
ownedSource: z.string().max(80).nullable().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 };
});
+64
View File
@@ -0,0 +1,64 @@
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,
owned: url.searchParams.get("sammlung") === "1" ? true : 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 };
});
+33
View File
@@ -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"`,
},
});
});
+29
View File
@@ -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,
};
});
+19
View File
@@ -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 },
};
});
+325
View File
@@ -0,0 +1,325 @@
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: "sammlung",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "Nur Titel, die in der eigenen Sammlung liegen.",
},
{ 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" },
owned: {
type: "boolean",
description: "Liegt der Titel in der eigenen Sammlung?",
},
ownedSource: {
type: "string",
nullable: true,
description: "Wo genau — Jellyfin, Plex, Blu-ray …",
},
},
},
},
},
},
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" },
});
}
+25
View File
@@ -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 };
});
+53
View File
@@ -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);
}
});
+27
View File
@@ -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 };
});
+21
View File
@@ -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 };
});
+1
View File
@@ -29,6 +29,7 @@ export default async function LibraryPage({
genre: params.genre || undefined,
tag: params.tag || undefined,
provider: params.dienst || undefined,
owned: params.sammlung === "1" ? true : undefined,
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
};
+52
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+50
View File
@@ -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>
);
}
+18 -5
View File
@@ -1,18 +1,30 @@
import type { Metadata, Viewport } from "next";
import { Outfit } from "next/font/google";
import localFont from "next/font/local";
import { BrandDefs } from "@/components/brand/FlimmerLogo";
import { ToastProvider } from "@/components/Toasts";
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
import { env } from "@/lib/env";
import "./globals.css";
/**
* Outfit wird beim Bauen heruntergeladen und mit ausgeliefert — die laufende
* Instanz fragt also niemals bei Google an.
* Outfit liegt als Datei im Repository.
*
* next/font/google würde die Schrift beim Bauen herunterladen — das macht den
* Build von einer Netzverbindung abhängig und ließ ihn im Container hängen.
* Für eine selbst gehostete Anwendung ist die mitgelieferte Datei ohnehin das
* Richtige: kein Abruf beim Bauen, keiner zur Laufzeit.
*
* Es ist die variable Fassung (Strichstärke 100–900), aufgeteilt in Latin und
* Latin-Extended — der Browser lädt nur, was die Seite wirklich braucht.
*/
const outfit = Outfit({
subsets: ["latin"],
const outfit = localFont({
src: [
{ path: "../../public/fonts/outfit-latin.woff2", weight: "100 900", style: "normal" },
{ path: "../../public/fonts/outfit-latin-ext.woff2", weight: "100 900", style: "normal" },
],
display: "swap",
variable: "--font-outfit",
fallback: ["Inter", "Segoe UI", "system-ui", "sans-serif"],
});
export const metadata: Metadata = {
@@ -75,6 +87,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
Zum Inhalt springen
</a>
<ToastProvider>{children}</ToastProvider>
<ServiceWorkerRegistration />
</body>
</html>
);
+30
View File
@@ -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>
);
}
+45
View File
@@ -120,6 +120,19 @@ export default async function TitlePage({ params }: { params: Params }) {
{year ? ` · ${year}` : ""}
{title.status ? ` · ${title.status}` : ""}
</p>
{entry?.owned && (
<span
className="rounded-full px-2.5 py-0.5 text-xs font-medium text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
title={
entry.ownedSource
? `In deiner Sammlung: ${entry.ownedSource}`
: "In deiner Sammlung"
}
>
Hab ich{entry.ownedSource ? ` · ${entry.ownedSource}` : ""}
</span>
)}
{entry && (
<span
className="rounded-full px-2.5 py-0.5 text-xs font-medium"
@@ -225,6 +238,7 @@ export default async function TitlePage({ params }: { params: Params }) {
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
) : (
<div className="fl-card p-4">
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
<ProviderList groups={providerGroups} region={user.region} />
</div>
)}
@@ -245,6 +259,8 @@ export default async function TitlePage({ params }: { params: Params }) {
whereToWatch={entry.whereToWatch}
tags={entry.tags.map((link) => link.tag.name)}
suggestion={suggestion}
owned={entry.owned}
ownedSource={entry.ownedSource}
/>
</div>
@@ -253,6 +269,7 @@ export default async function TitlePage({ params }: { params: Params }) {
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Wo läuft das?
</h2>
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
<ProviderList groups={providerGroups} region={user.region} />
</div>
)}
@@ -299,6 +316,34 @@ export default async function TitlePage({ params }: { params: Params }) {
);
}
/**
* Hinweis auf die eigene Sammlung — steht bewusst **vor** der
* Streaming-Verfügbarkeit: was man hat, ist die verlässlichere Antwort auf
* „wo kann ich das schauen".
*/
function OwnedNotice({ owned, source }: { owned: boolean; source: string | null }) {
if (!owned) return null;
return (
<p
className="mb-3 flex items-center gap-2 rounded-xl border px-3 py-2 text-sm"
style={{
borderColor: "color-mix(in oklab, var(--fl-success) 45%, transparent)",
backgroundColor: "color-mix(in oklab, var(--fl-success) 10%, transparent)",
color: "var(--fl-success)",
}}
>
<svg viewBox="0 0 24 24" className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>
In deiner Sammlung
{source ? <span className="font-medium"> · {source}</span> : null}
</span>
</p>
);
}
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
+171
View File
@@ -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>
);
}
+129
View File
@@ -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;
}
+95 -1
View File
@@ -1,7 +1,8 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import { setTagsAction, updateEntryAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
@@ -17,6 +18,8 @@ export function EntryNotes({
whereToWatch,
tags,
suggestion,
owned,
ownedSource,
}: {
entryId: string;
notes: string | null;
@@ -24,9 +27,12 @@ export function EntryNotes({
tags: string[];
/** Vorschlag aus der Streaming-Verfügbarkeit. */
suggestion: string | null;
owned: boolean;
ownedSource: string | null;
}) {
return (
<div className="space-y-4">
<OwnedEditor entryId={entryId} owned={owned} source={ownedSource} />
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
<TagEditor entryId={entryId} tags={tags} />
<Notes entryId={entryId} value={notes} />
@@ -34,6 +40,94 @@ export function EntryNotes({
);
}
/**
* „Hab ich" — der Titel liegt in der eigenen Sammlung.
*
* Bewusst getrennt von „wo schaue ich das": Streaming-Verfügbarkeit kommt und
* geht, der gekaufte Film auf dem Heimserver bleibt. Genau die Titel, die
* nirgends mehr laufen, will man ja wiederfinden.
*/
function OwnedEditor({
entryId,
owned,
source,
}: {
entryId: string;
owned: boolean;
source: string | null;
}) {
const save = useAutosave(entryId);
const [isOwned, setIsOwned] = useState(owned);
const [text, setText] = useState(source ?? "");
const initial = useRef(source ?? "");
return (
<div className="space-y-2 rounded-xl border border-[var(--fl-border)] p-3">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={isOwned}
onChange={(event) => {
setIsOwned(event.target.checked);
save({ owned: event.target.checked });
}}
className="sr-only"
/>
<span
aria-hidden
className={clsx(
"mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-all duration-200",
isOwned ? "border-transparent" : "border-[var(--fl-border-strong)]",
)}
style={isOwned ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{isOwned && (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="var(--fl-on-accent)" strokeWidth="3.5">
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">Hab ich</span>
<span className="block text-xs text-[var(--fl-faint)]">
Liegt in der eigenen Sammlung — unabhängig davon, ob es gerade irgendwo läuft.
</span>
</span>
</label>
{isOwned && (
<div className="pl-8">
<label htmlFor="ownedSource" className="sr-only">
Wo liegt der Titel?
</label>
<input
id="ownedSource"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ ownedSource: text });
}}
list="fl-owned-quellen"
placeholder="Jellyfin, Plex, NAS, Blu-ray …"
className="fl-input py-1.5 text-sm"
/>
<datalist id="fl-owned-quellen">
<option value="Jellyfin" />
<option value="Plex" />
<option value="Emby" />
<option value="NAS" />
<option value="Blu-ray" />
<option value="DVD" />
<option value="Gekauft (digital)" />
</datalist>
</div>
)}
</div>
);
}
function useAutosave(entryId: string) {
const { report } = useToasts();
const router = useRouter();
+22 -2
View File
@@ -19,8 +19,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
<div className="relative">
<Poster path={item.posterPath} alt={item.title} size="w342" />
{/* Status und Sammlung teilen sich die obere Zeile — unten sitzt bei
den Postern oft der Titel, dort würden sie ihn verdecken. */}
<span className="absolute left-2 top-2 flex flex-wrap gap-1">
<span
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
style={{
color: STATUS_COLOR[status],
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
@@ -30,6 +33,19 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
{STATUS_LABEL[status]}
</span>
{item.owned && (
<span
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
title={
item.ownedSource ? `In deiner Sammlung: ${item.ownedSource}` : "In deiner Sammlung"
}
>
Hab ich
</span>
)}
</span>
{item.rating != null && (
<span
className="absolute right-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
@@ -51,7 +67,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
<p className="text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""}
{item.owned && item.ownedSource
? ` · ${item.ownedSource}`
: item.whereToWatch
? ` · ${item.whereToWatch}`
: ""}
</p>
{showProgress && (
+12 -1
View File
@@ -56,7 +56,9 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const status = params.get("status");
const type = params.get("typ");
const sort = params.get("sortierung") ?? "aktualisiert";
const activeCount = ["genre", "tag", "dienst", "typ"].filter((key) => params.get(key)).length;
const activeCount = ["genre", "tag", "dienst", "typ", "sammlung"].filter((key) =>
params.get(key),
).length;
return (
<div className="space-y-3">
@@ -78,6 +80,15 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
{STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} />
</Chip>
))}
{facets.owned > 0 && (
<Chip
active={params.get("sammlung") === "1"}
onClick={() => apply({ sammlung: params.get("sammlung") === "1" ? null : "1" })}
>
Hab ich <Count value={facets.owned} />
</Chip>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
+757
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+13
View File
@@ -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();
}
+89
View File
@@ -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 };
}
+6
View File
@@ -49,6 +49,12 @@ export const env = {
tmdb: {
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"),
language: optional("TMDB_LANGUAGE", "de-DE"),
region: optional("TMDB_REGION", "DE"),
+12 -1
View File
@@ -110,7 +110,14 @@ export async function setStatus(userId: string, entryId: string, status: EntrySt
export async function updateEntryDetails(
userId: string,
entryId: string,
data: { rating?: number | null; notes?: string | null; whereToWatch?: string | null; favorite?: boolean },
data: {
rating?: number | null;
notes?: string | null;
whereToWatch?: string | null;
favorite?: boolean;
owned?: boolean;
ownedSource?: string | null;
},
) {
await requireEntry(userId, entryId);
@@ -127,6 +134,10 @@ export async function updateEntryDetails(
? { whereToWatch: data.whereToWatch?.trim() || null }
: {}),
...(data.favorite !== undefined ? { favorite: data.favorite } : {}),
...(data.owned !== undefined ? { owned: data.owned } : {}),
...(data.ownedSource !== undefined
? { ownedSource: data.ownedSource?.trim() || null }
: {}),
},
});
}
+283
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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()}`,
},
);
}
+12 -1
View File
@@ -19,6 +19,8 @@ export type LibraryFilters = {
genre?: string;
tag?: string;
provider?: string;
/** Nur Titel, die in der eigenen Sammlung liegen. */
owned?: boolean;
sort?: LibrarySort;
};
@@ -42,6 +44,8 @@ export type LibraryItem = {
favorite: boolean;
currentRun: number;
whereToWatch: string | null;
owned: boolean;
ownedSource: string | null;
tags: string[];
watched: number;
total: number;
@@ -56,6 +60,7 @@ export async function getLibrary(
userId,
deletedAt: null,
...(filters.status ? { status: filters.status } : {}),
...(filters.owned ? { owned: true } : {}),
...(filters.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}),
title: {
...(filters.type ? { type: filters.type } : {}),
@@ -116,6 +121,8 @@ export async function getLibrary(
favorite: entry.favorite,
currentRun: entry.currentRun,
whereToWatch: entry.whereToWatch,
owned: entry.owned,
ownedSource: entry.ownedSource,
tags: entry.tags.map((link) => link.tag.name),
watched,
total,
@@ -162,11 +169,13 @@ export type LibraryFacets = {
tags: string[];
providers: string[];
counts: Record<EntryStatus | "ALLE", number>;
/** Wie viele Titel in der eigenen Sammlung liegen. */
owned: number;
};
/** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */
export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
const [genreRows, tagRows, providerRows, statusRows, total] = await Promise.all([
const [genreRows, tagRows, providerRows, statusRows, total, owned] = await Promise.all([
prisma.$queryRaw<{ genre: string }[]>`
SELECT DISTINCT unnest(t."genres") AS genre
FROM "LibraryEntry" e
@@ -193,6 +202,7 @@ export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
_count: { _all: true },
}),
prisma.libraryEntry.count({ where: { userId, deletedAt: null } }),
prisma.libraryEntry.count({ where: { userId, deletedAt: null, owned: true } }),
]);
const counts = {
@@ -211,6 +221,7 @@ export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
tags: tagRows.map((row) => row.name),
providers: providerRows.map((row) => row.name),
counts,
owned,
};
}
+363
View File
@@ -0,0 +1,363 @@
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;
}
/**
* Meldet Filme von der Watchlist, die gerade erschienen sind.
*
* Betrachtet wird ein Fenster der letzten Tage statt eines Zustandswechsels:
* TMDB verschiebt Termine gelegentlich, und ein einmal verpasstes Datum soll
* nicht dazu führen, dass die Meldung für immer ausbleibt. Doppelt kommt sie
* trotzdem nie — dafür sorgt der Dedupe-Schlüssel.
*/
async function notifyReleases(windowDays = 7): Promise<number> {
const from = new Date(Date.now() - windowDays * 86_400_000);
const to = new Date();
const titles = await prisma.title.findMany({
where: {
type: "MOVIE",
releaseDate: { gte: from, lte: to },
entries: { some: { deletedAt: null, status: "WATCHLIST" } },
},
select: { id: true, title: true, releaseDate: true },
});
let sent = 0;
for (const title of titles) {
const watchers = await prisma.libraryEntry.findMany({
where: {
titleId: title.id,
deletedAt: null,
status: "WATCHLIST",
user: { notifyRelease: true, isActive: true },
},
select: { userId: true },
});
for (const watcher of watchers) {
const result = await notifyUser(watcher.userId, {
subject: `Jetzt erschienen: ${title.title}`,
body: `„${title.title}" von deiner Watchlist ist seit dem ${title.releaseDate?.toLocaleDateString(
"de-DE",
{ day: "2-digit", month: "2-digit", year: "numeric" },
)} draußen.`,
path: `/titel/${title.id}`,
dedupeKey: `release:${title.id}`,
});
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.");
}
// Erscheinungsmeldungen hängen nicht am TMDB-Abruf: sie stützen sich auf
// die Erscheinungsdaten, die schon im Katalog stehen.
try {
stats.notificationsSent += await notifyReleases();
} catch (error) {
stats.errors.push(
`Erscheinungsmeldungen: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
);
}
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.`,
);
}
+2 -2
View File
@@ -10,8 +10,8 @@ import { env } from "./env";
* Zeichen einzeln nach draußen trägt.
*/
const BASE = "https://api.themoviedb.org/3";
export const IMAGE_BASE = "https://image.tmdb.org/t/p";
const BASE = env.tmdb.baseUrl;
export const IMAGE_BASE = env.tmdb.imageBaseUrl;
export class TmdbNotConfiguredError extends Error {
constructor() {
+533
View File
@@ -0,0 +1,533 @@
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",
habich: "owned",
owned: "owned",
besitz: "owned",
sammlung: "ownedSource",
quelle: "ownedSource",
};
/**
* 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,
owned: /^(ja|yes|true|1|x)$/i.test(record.owned ?? ""),
ownedSource: record.ownedSource || 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;
owned?: boolean;
ownedSource?: 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 || record.owned || record.ownedSource) {
await prisma.libraryEntry.update({
where: { id: entry.id },
data: {
rating: record.rating && record.rating >= 1 && record.rating <= 10 ? record.rating : null,
notes: record.notes,
owned: record.owned ?? false,
ownedSource: record.ownedSource ?? null,
},
});
}
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",
"HabIch",
"Sammlung",
"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.owned ? "ja" : "nein",
entry.ownedSource ?? "",
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,
owned: entry.owned,
ownedSource: entry.ownedSource,
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),
})),
})),
};
}
+52
View File
@@ -336,3 +336,55 @@ test("fremde Einträge sind nicht erreichbar", async () => {
await prisma.user.delete({ where: { id: other.id } });
});
test("Sammlung ist unabhängig von der Streaming-Verfügbarkeit", async () => {
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
const fresh = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
assert.equal(fresh.owned, false, "standardmäßig gehört einem nichts");
assert.equal(fresh.ownedSource, null);
const owned = await updateEntryDetails(userId, entry.id, {
owned: true,
ownedSource: " Jellyfin ",
});
assert.equal(owned.owned, true);
assert.equal(owned.ownedSource, "Jellyfin", "Rand wird beschnitten");
// Der Fundort bleibt davon unberührt: das eine sagt, wo es läuft,
// das andere, wo es bei mir liegt.
assert.equal(
owned.whereToWatch,
"Disney Plus",
"der Vorschlag aus der Verfügbarkeit bleibt davon unberührt",
);
const released = await updateEntryDetails(userId, entry.id, {
owned: false,
ownedSource: "",
});
assert.equal(released.owned, false);
assert.equal(released.ownedSource, null, "leerer Text wird zu null");
});
test("Sammlung: Filter und Zähler in der Bibliothek", async () => {
const movie = await addToLibrary(userId, movieTitleId, "WATCHLIST");
const series = await addToLibrary(userId, seriesTitleId, "WATCHING");
await updateEntryDetails(userId, movie.id, { owned: true, ownedSource: "Blu-ray" });
const { getLibrary, getLibraryFacets } = await import("../src/lib/queries");
const alle = await getLibrary(userId, {});
assert.equal(alle.length, 2);
const nurSammlung = await getLibrary(userId, { owned: true });
assert.equal(nurSammlung.length, 1);
assert.equal(nurSammlung[0]?.entryId, movie.id);
assert.equal(nurSammlung[0]?.ownedSource, "Blu-ray");
const facets = await getLibraryFacets(userId);
assert.equal(facets.owned, 1, "der Zähler der Filterleiste stimmt");
assert.equal(facets.counts.ALLE, 2);
await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } });
});
+277
View File
@@ -0,0 +1,277 @@
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, runSync, 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 } });
});
test("erschienene Watchlist-Filme werden gemeldet — und nur einmal", async () => {
// Der Film liegt auf der Watchlist und ist gestern erschienen.
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await prisma.title.update({
where: { id: movieTitleId },
data: { releaseDate: new Date(Date.now() - 86_400_000) },
});
await prisma.user.update({ where: { id: userId }, data: { notifyRelease: true } });
await prisma.notificationChannel.create({
data: {
userId,
kind: "WEBHOOK",
name: "Test",
config: { webhookUrl: "http://127.0.0.1:9/nicht-erreichbar" },
},
});
await runSync({ limit: 0 });
const first = await prisma.notificationLog.findMany({
where: { userId, dedupeKey: `release:${movieTitleId}` },
});
assert.equal(first.length, 1, "die Meldung wird angelegt");
assert.match(first[0]?.subject ?? "", /Jetzt erschienen/);
// Ein zweiter Lauf meldet dasselbe nicht noch einmal.
await runSync({ limit: 0 });
const second = await prisma.notificationLog.count({
where: { userId, dedupeKey: `release:${movieTitleId}` },
});
assert.equal(second, 1);
await prisma.notificationChannel.deleteMany({ where: { userId } });
await prisma.libraryEntry.delete({ where: { id: entry.id } });
});