Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ee8664233 | ||
|
|
cd9953c47c | ||
|
|
18c87e6b2e | ||
|
|
afa3d74248 | ||
|
|
b3eb946cfa | ||
|
|
29ef710b9b | ||
|
|
3db6241e9a | ||
|
|
d8239441aa | ||
|
|
96a42594a9 | ||
|
|
a54e625ca8 | ||
|
|
ff9d380d42 | ||
|
|
8d6f1d044a | ||
|
|
cf38bd6854 | ||
|
|
0adcccc01a | ||
|
|
f02e3597b9 | ||
|
|
a9314acbb4 | ||
|
|
bb63827bf7 | ||
|
|
b25f0473fd | ||
|
|
969353ab32 | ||
|
|
f49ac17922 |
@@ -45,6 +45,9 @@ TMDB_ACCESS_TOKEN=
|
||||
# Sprache der Metadaten und Region für die Streaming-Verfügbarkeit
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -42,3 +42,4 @@ Thumbs.db
|
||||
/blob-report/
|
||||
.playwright/
|
||||
src/generated/
|
||||
.env.composetest
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||

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

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

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

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

|
||||
|
||||
„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
|
||||
|
||||

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

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

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

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

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

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

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

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

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

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

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

|
||||
|
||||
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
|
||||
|
||||

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

|
||||
|
||||
Über den Knopf im Kopfbereich wechselst du zwischen **Dunkel**, **Hell** und
|
||||
**System**. Die Wahl bleibt auf dem Gerät gespeichert und greift, bevor die
|
||||
Seite gezeichnet wird — es blitzt nichts auf.
|
||||
|
||||
---
|
||||
|
||||
## Und wenn etwas fehlt?
|
||||
|
||||
- **Die Suche findet nichts** → Es fehlt der TMDB-Schlüssel. Trag
|
||||
`TMDB_API_KEY` in die `.env` ein und starte die Instanz neu.
|
||||
- **Keine Streamingdienste** → Prüfe die Region in den Einstellungen. TMDB hat
|
||||
nicht für jedes Land Daten zu jedem Titel.
|
||||
- **Keine Benachrichtigungen** → Erst im Profil auswählen, *wovon* berichtet
|
||||
werden soll, dann einen Kanal anlegen und mit *Testen* prüfen.
|
||||
- **Neue Episoden fehlen** → Der Abgleich läuft einmal täglich. In der
|
||||
Verwaltung lässt er sich sofort anstoßen.
|
||||
|
||||
Alles Weitere zur Installation, zum Aktualisieren und zum Backup steht in der
|
||||
[README](../README.md).
|
||||
@@ -0,0 +1,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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 47 KiB |
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Flimmer — Service Worker
|
||||
*
|
||||
* Bewusst handgeschrieben und knapp gehalten. Drei Strategien:
|
||||
*
|
||||
* Bilder und statische Dateien → zuerst aus dem Zwischenspeicher.
|
||||
* Poster ändern sich unter einem TMDB-Pfad nie, das spart jede Anfrage.
|
||||
* Seitenaufrufe → zuerst das Netz, sonst der Speicher,
|
||||
* sonst die Offline-Seite. Ein Tracker, der veraltete Stände zeigt, wäre
|
||||
* schlimmer als einer, der ehrlich sagt: gerade keine Verbindung.
|
||||
* Alles Übrige (API, Aktionen) → gar kein Zwischenspeicher.
|
||||
*
|
||||
* Schreibende Anfragen laufen nie über den Service Worker.
|
||||
*/
|
||||
|
||||
const VERSION = "flimmer-v1";
|
||||
const SHELL_CACHE = `${VERSION}-shell`;
|
||||
const IMAGE_CACHE = `${VERSION}-images`;
|
||||
const PAGE_CACHE = `${VERSION}-pages`;
|
||||
|
||||
const OFFLINE_URL = "/offline";
|
||||
|
||||
const SHELL = [OFFLINE_URL, "/favicon.svg", "/icons/icon-192.png", "/manifest.webmanifest"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(SHELL_CACHE)
|
||||
.then((cache) => cache.addAll(SHELL))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(keys.filter((key) => !key.startsWith(VERSION)).map((key) => caches.delete(key))),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
/** Zwischenspeicher begrenzen, damit Poster ihn nicht endlos füllen. */
|
||||
async function trim(cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
if (keys.length <= maxEntries) return;
|
||||
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
|
||||
}
|
||||
|
||||
async function cacheFirst(request, cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(request, response.clone());
|
||||
void trim(cacheName, maxEntries);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
const cache = await caches.open(PAGE_CACHE);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(request, response.clone());
|
||||
void trim(PAGE_CACHE, 40);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
|
||||
const offline = await caches.match(OFFLINE_URL);
|
||||
if (offline) return offline;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Bilder aus dem eigenen Cache-Endpunkt und statische Next-Dateien
|
||||
if (url.pathname.startsWith("/api/image")) {
|
||||
event.respondWith(cacheFirst(request, IMAGE_CACHE, 400));
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith("/_next/static") || url.pathname.startsWith("/icons/")) {
|
||||
event.respondWith(cacheFirst(request, SHELL_CACHE, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
// Übrige API-Aufrufe nie zwischenspeichern
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(networkFirst(request));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
# Flimmer 0.6.0
|
||||
|
||||
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
|
||||
— und eine bebilderte Anleitung.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Benachrichtigungen** über vier Wege: E-Mail (SMTP), ntfy, Gotify und ein
|
||||
generischer Webhook, der zugleich Discord, Slack und Mattermost bedient.
|
||||
Jeder Nutzer richtet seine Kanäle selbst ein, kann sie einzeln stummschalten
|
||||
und mit einer Testnachricht ausprobieren. Dieselbe Meldung geht nie zweimal
|
||||
raus — auch ein Fehlversuch wird protokolliert, sonst spammt der nächste Lauf
|
||||
den Kanal voll, sobald er wieder erreichbar ist. Die E-Mail-Vorlage trägt
|
||||
Logo und Farbwelt von Flimmer.
|
||||
- **Täglicher Abgleich**: frischt laufende Serien bei TMDB auf, meldet neue
|
||||
Episoden und neu verfügbare Watchlist-Titel, räumt Papierkorb und abgelaufene
|
||||
Sitzungen. Eingebauter Zeitplan über `ENABLE_SCHEDULER` und `SYNC_HOUR`, von
|
||||
außen anstoßbar über `/api/cron/sync` mit `CRON_SECRET`, und in der
|
||||
Verwaltung per Knopfdruck. Ein einzelner Titel kann den Lauf nicht kippen.
|
||||
- **Import** aus Trakt.tv (JSON, egal ob Watchlist, History, Ratings oder
|
||||
Collection) und aus CSV mit Komma oder Semikolon. Der Import überschreibt
|
||||
nichts: was schon in der Bibliothek liegt, bleibt unangetastet.
|
||||
- **Export** der eigenen Daten als CSV oder als JSON mit allen Sichtungen und
|
||||
Universen.
|
||||
- **REST-API** unter `/api/v1` mit persönlichen API-Schlüsseln: Bibliothek
|
||||
lesen und pflegen, Episoden abhaken und zurücknehmen, suchen, Kalender,
|
||||
Statistik, Universen, Export. Beschreibung als OpenAPI 3.1 unter
|
||||
`/api/v1/openapi.json`, dazu `docs/API.md` mit Beispielen.
|
||||
- **Einstellungsseiten** für Profil, Region, Passwort, Benachrichtigungen,
|
||||
API-Schlüssel sowie Import und Export.
|
||||
- **Verwaltungsbereich**: Instanzname, Registrierung öffnen und schließen,
|
||||
öffentliche Profile erlauben, Konten sperren und Rechte vergeben, Abgleich
|
||||
anstoßen und der Zustand der Instanz auf einen Blick. Die letzte
|
||||
Administration lässt sich nicht entmachten.
|
||||
- **Öffentliche Profilseite** unter `/u/<adresse>` — Zahlen und zuletzt
|
||||
Gesehenes, aber keine Notizen, keine Watchlist, keine E-Mail-Adresse.
|
||||
Abschaltbar durch den Nutzer *und* instanzweit durch den Betreiber.
|
||||
- **PWA-Feinschliff**: eigener Service Worker (Poster aus dem Zwischenspeicher,
|
||||
Seiten zuerst aus dem Netz, Offline-Rückfall) und eine Offline-Seite.
|
||||
- **`TMDB_BASE_URL`** für Instanzen hinter einem eigenen Proxy — und für den
|
||||
mitgelieferten TMDB-Ersatz `scripts/tmdb-stub.mjs`, mit dem sich die Suche
|
||||
ohne Schlüssel aus den Beispieldaten ausprobieren lässt.
|
||||
- **Bebilderte Anleitung** `docs/ANLEITUNG.md` mit 18 Screenshots aus der
|
||||
laufenden Instanz, aufgenommen von `npm run screenshots`.
|
||||
- **9 weitere Tests** für CSV- und Trakt-Import, Export, das Aufräumen des
|
||||
Papierkorbs und den Schutz vor Doppelversand — zusammen 49.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,34 @@
|
||||
import sharp from "sharp";
|
||||
import { readdir, unlink, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Verkleinert die aufgenommenen Screenshots für das Repository.
|
||||
* Die Aufnahme läuft mit doppelter Pixeldichte (scharfe Schrift), abgelegt
|
||||
* wird die halbe Kantenlänge als WebP — das spart rund 90 Prozent.
|
||||
*/
|
||||
const DIR = "docs/screenshots";
|
||||
const files = (await readdir(DIR)).filter((name) => name.endsWith(".png"));
|
||||
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
|
||||
for (const name of files) {
|
||||
const source = join(DIR, name);
|
||||
before += (await stat(source)).size;
|
||||
|
||||
const image = sharp(source);
|
||||
const meta = await image.metadata();
|
||||
const target = join(DIR, name.replace(/\.png$/, ".webp"));
|
||||
|
||||
await image
|
||||
.resize({ width: Math.round((meta.width ?? 1400) / 2) })
|
||||
.webp({ quality: 82, effort: 5 })
|
||||
.toFile(target);
|
||||
|
||||
after += (await stat(target)).size;
|
||||
await unlink(source);
|
||||
}
|
||||
|
||||
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
|
||||
console.log(`${files.length} Bilder: ${mb(before)} MB -> ${mb(after)} MB`);
|
||||
@@ -0,0 +1,99 @@
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
|
||||
const OUT = "docs/screenshots";
|
||||
const BASE = "http://localhost:3011";
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const errs = [];
|
||||
|
||||
async function session(width, height, theme) {
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
deviceScaleFactor: 2,
|
||||
colorScheme: theme === "light" ? "light" : "dark",
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
});
|
||||
await ctx.addInitScript((mode) => localStorage.setItem("flimmer-theme", mode), theme);
|
||||
// Der Entwicklungs-Indikator von Next hat in einer Anleitung nichts verloren.
|
||||
await ctx.addStyleTag?.({ content: "nextjs-portal { display: none !important; }" });
|
||||
const page = await ctx.newPage();
|
||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
|
||||
page.on("pageerror", (e) => errs.push(String(e).slice(0, 160)));
|
||||
return { ctx, page };
|
||||
}
|
||||
|
||||
async function login(page) {
|
||||
await page.goto(`${BASE}/anmelden`);
|
||||
await page.fill("#email", "demo@flimmer.local");
|
||||
await page.fill("#password", "flimmer-demo-2026");
|
||||
await Promise.all([page.waitForURL(`${BASE}/`), page.click("button[type=submit]")]);
|
||||
}
|
||||
|
||||
async function shot(page, name, url, opts = {}) {
|
||||
await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" });
|
||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
|
||||
await page.waitForTimeout(opts.wait ?? 900);
|
||||
if (opts.before) await opts.before(page);
|
||||
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: Boolean(opts.full) });
|
||||
console.log(" ", name);
|
||||
}
|
||||
|
||||
// --- Anmeldung (ohne Sitzung) ---
|
||||
{
|
||||
const { ctx, page } = await session(1280, 800, "dark");
|
||||
await shot(page, "01-anmeldung", "/anmelden");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// --- Angemeldet, dunkel ---
|
||||
{
|
||||
const { ctx, page } = await session(1400, 900, "dark");
|
||||
await login(page);
|
||||
|
||||
await shot(page, "02-uebersicht", "/", { full: true });
|
||||
await shot(page, "03-suche", "/suche?q=sternenpfad");
|
||||
await shot(page, "05-bibliothek", "/bibliothek");
|
||||
await shot(page, "09-kalender", "/kalender");
|
||||
await shot(page, "10-statistik", "/statistik", { full: true });
|
||||
await shot(page, "11-einstellungen", "/einstellungen");
|
||||
await shot(page, "12-benachrichtigungen", "/einstellungen/benachrichtigungen");
|
||||
await shot(page, "13-api", "/einstellungen/api");
|
||||
await shot(page, "14-daten", "/einstellungen/daten");
|
||||
await shot(page, "15-verwaltung", "/verwaltung", { full: true });
|
||||
await shot(page, "16-papierkorb", "/papierkorb");
|
||||
|
||||
// Detailseite und Universum brauchen IDs aus der Bibliothek
|
||||
await page.goto(`${BASE}/bibliothek?q=nordlicht`, { waitUntil: "networkidle" });
|
||||
const titleHref = await page.locator("a[href^='/titel/']").first().getAttribute("href");
|
||||
if (titleHref) await shot(page, "04-serie", titleHref, { full: true });
|
||||
|
||||
await page.goto(`${BASE}/universen`, { waitUntil: "networkidle" });
|
||||
await shot(page, "06-universen", "/universen");
|
||||
const uniHref = await page.locator("a[href^='/universen/']").first().getAttribute("href");
|
||||
if (uniHref) await shot(page, "07-universum", uniHref, { full: true });
|
||||
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// --- Hell ---
|
||||
{
|
||||
const { ctx, page } = await session(1400, 900, "light");
|
||||
await login(page);
|
||||
await shot(page, "08-hell", "/bibliothek");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// --- Mobil ---
|
||||
{
|
||||
const { ctx, page } = await session(430, 900, "dark");
|
||||
await login(page);
|
||||
await shot(page, "17-mobil-uebersicht", "/");
|
||||
await shot(page, "18-mobil-serie", "/bibliothek");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
console.log("Fehler:", errs.length ? [...new Set(errs)].slice(0, 5) : "keine");
|
||||
await browser.close();
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Kleiner TMDB-Ersatz für Screenshots und manuelles Ausprobieren.
|
||||
*
|
||||
* node scripts/tmdb-stub.mjs [port]
|
||||
*
|
||||
* Beantwortet die Suchendpunkte aus dem lokalen Katalog (den das Seed-Skript
|
||||
* angelegt hat) im Format der TMDB-API. Damit lässt sich die Suchseite auch
|
||||
* ohne echten TMDB-Schlüssel zeigen — für Screenshots der Anleitung und um
|
||||
* die Oberfläche auszuprobieren, bevor man einen Schlüssel besorgt.
|
||||
*
|
||||
* Verwendung zusammen mit:
|
||||
* TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "../src/generated/prisma/client.ts";
|
||||
|
||||
try {
|
||||
process.loadEnvFile(".env");
|
||||
} catch {
|
||||
// Werte kommen aus der Umgebung.
|
||||
}
|
||||
|
||||
const port = Number(process.argv[2] ?? 3099);
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
|
||||
function toResult(title, mediaType) {
|
||||
const date = title.releaseDate?.toISOString().slice(0, 10) ?? null;
|
||||
return {
|
||||
id: title.tmdbId,
|
||||
media_type: mediaType,
|
||||
name: mediaType === "tv" ? title.title : undefined,
|
||||
title: mediaType === "movie" ? title.title : undefined,
|
||||
original_name: title.originalTitle ?? undefined,
|
||||
original_title: title.originalTitle ?? undefined,
|
||||
overview: title.overview ?? "",
|
||||
poster_path: title.posterPath,
|
||||
backdrop_path: title.backdropPath,
|
||||
first_air_date: mediaType === "tv" ? date : undefined,
|
||||
release_date: mediaType === "movie" ? date : undefined,
|
||||
vote_average: title.voteAverage ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function search(query, type) {
|
||||
const titles = await prisma.title.findMany({
|
||||
where: {
|
||||
tmdbId: { not: null },
|
||||
...(type ? { type } : {}),
|
||||
OR: [
|
||||
{ title: { contains: query, mode: "insensitive" } },
|
||||
{ originalTitle: { contains: query, mode: "insensitive" } },
|
||||
{ genres: { has: query } },
|
||||
],
|
||||
},
|
||||
orderBy: { releaseDate: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return titles.map((title) => toResult(title, title.type === "SERIES" ? "tv" : "movie"));
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
|
||||
const query = url.searchParams.get("query") ?? "";
|
||||
|
||||
const send = (body, status = 200) => {
|
||||
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
try {
|
||||
if (url.pathname.startsWith("/3/search/")) {
|
||||
const kind = url.pathname.split("/").pop();
|
||||
const type = kind === "tv" ? "SERIES" : kind === "movie" ? "MOVIE" : null;
|
||||
const results = await search(query, type);
|
||||
return send({ page: 1, results, total_pages: 1, total_results: results.length });
|
||||
}
|
||||
|
||||
return send({ status_message: `Kein Ersatz für ${url.pathname}` }, 404);
|
||||
} catch (error) {
|
||||
return send({ status_message: String(error) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`TMDB-Ersatz läuft auf http://127.0.0.1:${port}/3`);
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
server.close();
|
||||
void prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
checkPasswordStrength,
|
||||
createApiKey,
|
||||
destroyAllSessions,
|
||||
getSessionUser,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
} from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { setSetting } from "@/lib/settings";
|
||||
import { sendTestNotification } from "@/lib/notify";
|
||||
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
|
||||
import { runSync } from "@/lib/sync";
|
||||
|
||||
/** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */
|
||||
|
||||
export type SettingsResult = { ok: boolean; message?: string; secret?: string };
|
||||
|
||||
async function run(
|
||||
fn: (userId: string) => Promise<{ message?: string; secret?: string } | void>,
|
||||
): Promise<SettingsResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const result = (await fn(user.id)) ?? {};
|
||||
revalidatePath("/einstellungen", "layout");
|
||||
revalidatePath("/", "layout");
|
||||
return { ok: true, ...result };
|
||||
} catch (error) {
|
||||
console.error("Einstellungs-Aktion fehlgeschlagen:", error);
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function requireAdminUser() {
|
||||
const user = await getSessionUser();
|
||||
if (!user || user.role !== "ADMIN") {
|
||||
throw new Error("Dafür fehlen dir die Rechte.");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profil
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const profileSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
region: z.string().trim().length(2).toUpperCase(),
|
||||
publicProfile: z.boolean(),
|
||||
profileSlug: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z0-9-]{3,40}$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 40 Zeichen.")
|
||||
.nullable(),
|
||||
notifyNewEpisode: z.boolean(),
|
||||
notifyRelease: z.boolean(),
|
||||
notifyAvailability: z.boolean(),
|
||||
});
|
||||
|
||||
export async function updateProfileAction(data: unknown) {
|
||||
return run(async (userId) => {
|
||||
const parsed = profileSchema.parse(data);
|
||||
|
||||
if (parsed.publicProfile && !parsed.profileSlug) {
|
||||
throw new Error("Für ein öffentliches Profil braucht es eine Adresse.");
|
||||
}
|
||||
|
||||
if (parsed.profileSlug) {
|
||||
const taken = await prisma.user.findFirst({
|
||||
where: { profileSlug: parsed.profileSlug, id: { not: userId } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (taken) throw new Error("Diese Profiladresse ist schon vergeben.");
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: userId }, data: parsed });
|
||||
return { message: "Gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
const passwordSchema = z.object({
|
||||
current: z.string().min(1),
|
||||
next: z.string().min(1),
|
||||
repeat: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function changePasswordAction(data: unknown) {
|
||||
return run(async (userId) => {
|
||||
const parsed = passwordSchema.parse(data);
|
||||
|
||||
if (parsed.next !== parsed.repeat) throw new Error("Die neuen Passwörter stimmen nicht überein.");
|
||||
const weak = checkPasswordStrength(parsed.next);
|
||||
if (weak) throw new Error(weak);
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { passwordHash: true },
|
||||
});
|
||||
if (!user.passwordHash) {
|
||||
throw new Error("Dieses Konto meldet sich über SSO an und hat kein Passwort.");
|
||||
}
|
||||
if (!(await verifyPassword(parsed.current, user.passwordHash))) {
|
||||
throw new Error("Das bisherige Passwort stimmt nicht.");
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: await hashPassword(parsed.next) },
|
||||
});
|
||||
|
||||
// Nach einem Passwortwechsel fliegen alle anderen Geräte raus.
|
||||
await destroyAllSessions(userId);
|
||||
return { message: "Passwort geändert — bitte neu anmelden." };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API-Schlüssel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createApiKeyAction(name: string) {
|
||||
return run(async (userId) => {
|
||||
const clean = z.string().trim().min(1).max(60).parse(name);
|
||||
const { plain } = await createApiKey(userId, clean);
|
||||
// Der Klartext existiert nur hier — danach steht in der Datenbank nur der Hash.
|
||||
return { message: "Schlüssel angelegt", secret: plain };
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeApiKeyAction(keyId: string) {
|
||||
return run(async (userId) => {
|
||||
await prisma.apiKey.updateMany({
|
||||
where: { id: keyId, userId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { message: "Schlüssel zurückgezogen" };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benachrichtigungskanäle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const channelSchema = z.object({
|
||||
kind: z.enum(["EMAIL", "NTFY", "GOTIFY", "WEBHOOK"]),
|
||||
name: z.string().trim().min(1).max(60),
|
||||
config: z.record(z.string(), z.string().max(500)).default({}),
|
||||
});
|
||||
|
||||
export async function saveChannelAction(data: unknown, channelId?: string) {
|
||||
return run(async (userId) => {
|
||||
const parsed = channelSchema.parse(data);
|
||||
|
||||
if (channelId) {
|
||||
await prisma.notificationChannel.updateMany({
|
||||
where: { id: channelId, userId },
|
||||
data: { kind: parsed.kind, name: parsed.name, config: parsed.config },
|
||||
});
|
||||
return { message: "Kanal gespeichert" };
|
||||
}
|
||||
|
||||
await prisma.notificationChannel.create({
|
||||
data: { userId, kind: parsed.kind, name: parsed.name, config: parsed.config },
|
||||
});
|
||||
return { message: "Kanal angelegt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleChannelAction(channelId: string, enabled: boolean) {
|
||||
return run(async (userId) => {
|
||||
await prisma.notificationChannel.updateMany({
|
||||
where: { id: channelId, userId },
|
||||
data: { enabled },
|
||||
});
|
||||
return { message: enabled ? "Kanal aktiv" : "Kanal ruht" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteChannelAction(channelId: string) {
|
||||
return run(async (userId) => {
|
||||
await prisma.notificationChannel.deleteMany({ where: { id: channelId, userId } });
|
||||
return { message: "Kanal entfernt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function testChannelAction(channelId: string) {
|
||||
return run(async (userId) => {
|
||||
await sendTestNotification(userId, channelId);
|
||||
return { message: "Testnachricht verschickt" };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ImportResult = { ok: boolean; message?: string; summary?: ImportSummary };
|
||||
|
||||
export async function importDataAction(
|
||||
format: "csv" | "trakt",
|
||||
content: string,
|
||||
): Promise<ImportResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
if (content.length > 8_000_000) {
|
||||
return { ok: false, message: "Die Datei ist zu groß (mehr als 8 MB)." };
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = format === "csv" ? await importCsv(user.id, content) : await importTrakt(user.id, content);
|
||||
revalidatePath("/bibliothek", "layout");
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: `${summary.added} aufgenommen, ${summary.skipped} übersprungen`,
|
||||
summary,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Import fehlgeschlagen:", error);
|
||||
return { ok: false, message: error instanceof Error ? error.message : "Import fehlgeschlagen." };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verwaltung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function setRegistrationAction(enabled: boolean) {
|
||||
return run(async () => {
|
||||
await requireAdminUser();
|
||||
await setSetting("registrationEnabled", enabled);
|
||||
return { message: enabled ? "Registrierung offen" : "Registrierung geschlossen" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function setInstanceNameAction(name: string) {
|
||||
return run(async () => {
|
||||
await requireAdminUser();
|
||||
await setSetting("instanceName", z.string().trim().min(1).max(60).parse(name));
|
||||
return { message: "Name gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function setPublicProfilesAction(enabled: boolean) {
|
||||
return run(async () => {
|
||||
await requireAdminUser();
|
||||
await setSetting("publicProfilesEnabled", enabled);
|
||||
return { message: enabled ? "Öffentliche Profile erlaubt" : "Öffentliche Profile abgeschaltet" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function setUserActiveAction(targetId: string, active: boolean) {
|
||||
return run(async (userId) => {
|
||||
await requireAdminUser();
|
||||
if (targetId === userId) throw new Error("Dich selbst kannst du nicht sperren.");
|
||||
|
||||
await prisma.user.update({ where: { id: targetId }, data: { isActive: active } });
|
||||
if (!active) await destroyAllSessions(targetId);
|
||||
|
||||
revalidatePath("/verwaltung", "layout");
|
||||
return { message: active ? "Konto freigegeben" : "Konto gesperrt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function setUserRoleAction(targetId: string, role: "ADMIN" | "USER") {
|
||||
return run(async (userId) => {
|
||||
await requireAdminUser();
|
||||
if (targetId === userId) throw new Error("Deine eigene Rolle kannst du nicht ändern.");
|
||||
|
||||
if (role === "USER") {
|
||||
const admins = await prisma.user.count({ where: { role: "ADMIN", isActive: true } });
|
||||
if (admins <= 1) throw new Error("Es muss mindestens eine Administration geben.");
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: targetId }, data: { role } });
|
||||
revalidatePath("/verwaltung", "layout");
|
||||
return { message: role === "ADMIN" ? "Zur Administration ernannt" : "Rechte entzogen" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSyncAction() {
|
||||
return run(async () => {
|
||||
await requireAdminUser();
|
||||
const stats = await runSync({ limit: 40 });
|
||||
revalidatePath("/verwaltung", "layout");
|
||||
|
||||
return {
|
||||
message:
|
||||
stats.errors.length === 0
|
||||
? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen`
|
||||
: `Mit ${stats.errors.length} Fehlern beendet: ${stats.errors[0]}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/lib/env";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { runSync } from "@/lib/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Abgleich von außen anstoßen.
|
||||
*
|
||||
* curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
|
||||
* https://flimmer.example/api/cron/sync
|
||||
*
|
||||
* Zugelassen ist entweder das Cron-Geheimnis oder eine angemeldete
|
||||
* Administration — Letzteres für den Knopf im Verwaltungsbereich.
|
||||
*/
|
||||
async function authorize(request: Request): Promise<boolean> {
|
||||
const header = request.headers.get("authorization");
|
||||
if (env.scheduler.cronSecret && header === `Bearer ${env.scheduler.cronSecret}`) return true;
|
||||
|
||||
const user = await getSessionUser();
|
||||
return user?.role === "ADMIN";
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!(await authorize(request))) {
|
||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 401 });
|
||||
}
|
||||
|
||||
const stats = await runSync();
|
||||
return NextResponse.json({ ok: stats.errors.length === 0, ...stats });
|
||||
}
|
||||
|
||||
/** GET erlaubt einfache Uptime-Cron-Dienste, die kein POST können. */
|
||||
export async function GET(request: Request) {
|
||||
return POST(request);
|
||||
}
|
||||
@@ -0,0 +1,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 };
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withApi } from "@/lib/api";
|
||||
import { exportCsv, exportJson } from "@/lib/transfer";
|
||||
|
||||
/**
|
||||
* GET /api/v1/export?format=json|csv
|
||||
*
|
||||
* Vollständiger Export der eigenen Daten. JSON enthält zusätzlich Sichtungen
|
||||
* und Universen, CSV ist die schlanke Tabellenform für Tabellenkalkulationen.
|
||||
*/
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
const format = url.searchParams.get("format") === "csv" ? "csv" : "json";
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (format === "csv") {
|
||||
const csv = await exportCsv(user.id);
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"content-type": "text/csv; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="flimmer-${stamp}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const data = await exportJson(user.id);
|
||||
return new NextResponse(JSON.stringify(data, null, 2), {
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="flimmer-${stamp}.json"`,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { withApi } from "@/lib/api";
|
||||
import { getUpcomingEpisodes } from "@/lib/stats";
|
||||
|
||||
/**
|
||||
* GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30
|
||||
*
|
||||
* Kommende Episoden der getrackten Serien. Ohne Angabe: die nächsten 30 Tage.
|
||||
*/
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const parse = (value: string | null, fallback: Date) => {
|
||||
if (!value) return fallback;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? fallback : date;
|
||||
};
|
||||
|
||||
const from = parse(url.searchParams.get("von"), new Date());
|
||||
const to = parse(url.searchParams.get("bis"), new Date(Date.now() + 30 * 86_400_000));
|
||||
|
||||
const episodes = await getUpcomingEpisodes(user.id, from, to);
|
||||
|
||||
return {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
total: episodes.length,
|
||||
items: episodes,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { withApi } from "@/lib/api";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** GET /api/v1/me — wer bin ich, und wie groß ist meine Bibliothek? */
|
||||
export const GET = withApi(async (_request, user) => {
|
||||
const [entries, watches] = await Promise.all([
|
||||
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||
prisma.watch.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
region: user.region,
|
||||
library: { entries, watches },
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,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" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { withApi } from "@/lib/api";
|
||||
import {
|
||||
getGenreDistribution,
|
||||
getMonthlyActivity,
|
||||
getProviderDistribution,
|
||||
getTotals,
|
||||
getYearReview,
|
||||
} from "@/lib/stats";
|
||||
|
||||
/** GET /api/v1/statistik?jahr=2026 — alle Kennzahlen auf einmal. */
|
||||
export const GET = withApi(async (request, user) => {
|
||||
const url = new URL(request.url);
|
||||
const parsed = Number.parseInt(url.searchParams.get("jahr") ?? "", 10);
|
||||
const year = Number.isFinite(parsed) ? parsed : new Date().getFullYear();
|
||||
|
||||
const [totals, monthly, providers, genres, review] = await Promise.all([
|
||||
getTotals(user.id),
|
||||
getMonthlyActivity(user.id, 12),
|
||||
getProviderDistribution(user.id),
|
||||
getGenreDistribution(user.id),
|
||||
getYearReview(user.id, year),
|
||||
]);
|
||||
|
||||
return { totals, monthly, providers, genres, yearReview: review };
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { apiError, withApi } from "@/lib/api";
|
||||
import { env } from "@/lib/env";
|
||||
import { searchMovies, searchMulti, searchSeries, TmdbNotConfiguredError } from "@/lib/tmdb";
|
||||
|
||||
/**
|
||||
* GET /api/v1/suche?q=…&typ=serie|film
|
||||
*
|
||||
* Durchsucht TMDB. Die Treffer tragen die TMDB-ID — damit lässt sich der
|
||||
* Titel über POST /api/v1/bibliothek direkt aufnehmen.
|
||||
*/
|
||||
export const GET = withApi(async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const query = (url.searchParams.get("q") ?? "").trim();
|
||||
const type = url.searchParams.get("typ");
|
||||
|
||||
if (query.length < 2) {
|
||||
return apiError("Der Suchbegriff braucht mindestens zwei Zeichen.", 400);
|
||||
}
|
||||
if (!env.tmdb.isConfigured) {
|
||||
return apiError("Auf dieser Instanz ist kein TMDB-Schlüssel hinterlegt.", 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const data =
|
||||
type === "serie"
|
||||
? await searchSeries(query)
|
||||
: type === "film"
|
||||
? await searchMovies(query)
|
||||
: await searchMulti(query);
|
||||
|
||||
return {
|
||||
query,
|
||||
total: data.total_results,
|
||||
items: data.results.map((item) => {
|
||||
const isSeries =
|
||||
item.media_type === "tv" || (type === "serie" && item.media_type === undefined);
|
||||
const date = isSeries ? item.first_air_date : item.release_date;
|
||||
return {
|
||||
tmdbId: item.id,
|
||||
type: isSeries ? "SERIES" : "MOVIE",
|
||||
title: item.name ?? item.title ?? "Ohne Titel",
|
||||
overview: item.overview ?? null,
|
||||
posterPath: item.poster_path ?? null,
|
||||
year: date ? Number.parseInt(date.slice(0, 4), 10) : null,
|
||||
voteAverage: item.vote_average ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TmdbNotConfiguredError) return apiError(error.message, 503);
|
||||
return apiError(error instanceof Error ? error.message : "Die Suche ist fehlgeschlagen.", 502);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, readJson, withApi } from "@/lib/api";
|
||||
import { addTitle, getUniverse } from "@/lib/universes";
|
||||
|
||||
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
|
||||
export const GET = withApi(async (request, user, context) => {
|
||||
const { id } = await context.params;
|
||||
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const universe = await getUniverse(user.id, id, url.searchParams.get("reihenfolge") ?? undefined);
|
||||
if (!universe) return apiError("Dieses Universum gibt es nicht.", 404);
|
||||
|
||||
return universe;
|
||||
});
|
||||
|
||||
const addSchema = z.object({ titleId: z.string().min(1) });
|
||||
|
||||
/** POST /api/v1/universen/{id} — Titel aufnehmen. */
|
||||
export const POST = withApi(async (request, user, context) => {
|
||||
const { id } = await context.params;
|
||||
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
|
||||
|
||||
const body = await readJson(request, addSchema);
|
||||
await addTitle(user.id, id, body.titleId);
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
import { readJson, withApi } from "@/lib/api";
|
||||
import { createUniverse, listUniverses } from "@/lib/universes";
|
||||
|
||||
/** GET /api/v1/universen — eigene und geteilte Universen. */
|
||||
export const GET = withApi(async (_request, user) => {
|
||||
const universes = await listUniverses(user.id);
|
||||
return { total: universes.length, items: universes };
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/v1/universen — neues Universum anlegen. */
|
||||
export const POST = withApi(async (request, user) => {
|
||||
const body = await readJson(request, createSchema);
|
||||
const universe = await createUniverse(user.id, body.name, body.description);
|
||||
return { id: universe.id, name: universe.name, slug: universe.slug };
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { ApiKeys } from "@/components/settings/Forms";
|
||||
|
||||
export const metadata: Metadata = { title: "API-Schlüssel" };
|
||||
|
||||
export default async function ApiPage() {
|
||||
const user = await requireUser("/einstellungen/api");
|
||||
|
||||
const keys = await prisma.apiKey.findMany({
|
||||
where: { userId: user.id, revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true },
|
||||
});
|
||||
|
||||
const example = [
|
||||
'curl -H "Authorization: Bearer <dein-schlüssel>" \\',
|
||||
` ${env.appUrl}/api/v1/bibliothek?status=WATCHING`,
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">API-Schlüssel</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Für Skripte und eigene Anwendungen. Ein Schlüssel hat dieselben Rechte wie dein Konto —
|
||||
gib ihn nicht weiter und zieh ihn zurück, sobald du ihn nicht mehr brauchst.
|
||||
</p>
|
||||
<ApiKeys keys={keys} />
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-3 text-lg font-semibold">So wird er benutzt</h2>
|
||||
<pre className="overflow-x-auto rounded-xl bg-[var(--fl-surface-2)] p-3 text-xs leading-relaxed">
|
||||
{example}
|
||||
</pre>
|
||||
<p className="mt-3 text-sm text-[var(--fl-muted)]">
|
||||
Die vollständige Beschreibung steht in{" "}
|
||||
<a
|
||||
href="/api/v1/openapi.json"
|
||||
className="text-[var(--fl-accent)] underline-offset-4 hover:underline"
|
||||
>
|
||||
openapi.json
|
||||
</a>{" "}
|
||||
und in <code>docs/API.md</code> im Repository.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { ChannelList } from "@/components/settings/Forms";
|
||||
|
||||
export const metadata: Metadata = { title: "Benachrichtigungen" };
|
||||
|
||||
export default async function NotificationsPage() {
|
||||
const user = await requireUser("/einstellungen/benachrichtigungen");
|
||||
|
||||
const [channels, recent] = await Promise.all([
|
||||
prisma.notificationChannel.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
prisma.notificationLog.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { sentAt: "desc" },
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">Kanäle</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Wohin Flimmer dir Bescheid gibt. Was gemeldet wird, stellst du im Profil ein.
|
||||
</p>
|
||||
<ChannelList
|
||||
channels={channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
kind: channel.kind,
|
||||
name: channel.name,
|
||||
config: (channel.config ?? {}) as Record<string, string>,
|
||||
enabled: channel.enabled,
|
||||
}))}
|
||||
smtpReady={env.smtp.isConfigured}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Zuletzt verschickt</h2>
|
||||
{recent.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">Noch nichts verschickt.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--fl-border)] text-sm">
|
||||
{recent.map((entry) => (
|
||||
<li key={entry.id} className="py-2.5">
|
||||
<p className="font-medium">{entry.subject}</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{entry.sentAt.toLocaleString("de-DE")}
|
||||
{entry.ok ? "" : ` · fehlgeschlagen: ${entry.error ?? "unbekannt"}`}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ImportForm } from "@/components/settings/Forms";
|
||||
|
||||
export const metadata: Metadata = { title: "Import und Export" };
|
||||
|
||||
export default async function DataPage() {
|
||||
const user = await requireUser("/einstellungen/daten");
|
||||
|
||||
const [entries, watches] = await Promise.all([
|
||||
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||
prisma.watch.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">Import</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Bringt Titel aus einem anderen Werkzeug mit.
|
||||
</p>
|
||||
<ImportForm />
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="text-lg font-semibold">Export</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Deine Daten gehören dir: {entries} Einträge und {watches} Sichtungen zum Mitnehmen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href="/api/v1/export?format=json" className="fl-btn fl-btn-primary" download>
|
||||
Als JSON
|
||||
</a>
|
||||
<a href="/api/v1/export?format=csv" className="fl-btn fl-btn-ghost" download>
|
||||
Als CSV
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-[var(--fl-faint)]">
|
||||
JSON enthält zusätzlich alle Sichtungen und Universen, CSV ist die schlanke Tabellenform
|
||||
für Tabellenkalkulationen.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { SettingsNav } from "@/components/settings/SettingsNav";
|
||||
|
||||
/** Rahmen der Einstellungen: Seitennavigation links, Inhalt rechts. */
|
||||
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireUser("/einstellungen");
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||
<SettingsNav isAdmin={user.role === "ADMIN"} />
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PasswordForm, ProfileForm } from "@/components/settings/Forms";
|
||||
import pkg from "../../../package.json";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await requireUser("/einstellungen");
|
||||
const settings = await getSettings();
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: session.id },
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
region: true,
|
||||
publicProfile: true,
|
||||
profileSlug: true,
|
||||
notifyNewEpisode: true,
|
||||
notifyRelease: true,
|
||||
notifyAvailability: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { passwordHash, ...profile } = user;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Profil</h2>
|
||||
<ProfileForm user={profile} publicProfilesEnabled={settings.publicProfilesEnabled} />
|
||||
</section>
|
||||
|
||||
{passwordHash && (
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Passwort</h2>
|
||||
<PasswordForm />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
|
||||
import { formatMinutes, formatRelative } from "@/lib/labels";
|
||||
import { FlimmerLogo } from "@/components/brand/FlimmerLogo";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
/**
|
||||
* Öffentliche Profilseite.
|
||||
*
|
||||
* Zeigt Zahlen und zuletzt Gesehenes — **keine** Notizen, keine Watchlist,
|
||||
* keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat. Die Seite ist
|
||||
* nur erreichbar, wenn der Nutzer sie freigeschaltet hat *und* der Betreiber
|
||||
* öffentliche Profile instanzweit erlaubt.
|
||||
*/
|
||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { profileSlug: slug, publicProfile: true, isActive: true },
|
||||
select: { name: true },
|
||||
});
|
||||
return user ? { title: `${user.name} · Profil` } : { title: "Profil" };
|
||||
}
|
||||
|
||||
export default async function PublicProfilePage({ params }: { params: Params }) {
|
||||
const { slug } = await params;
|
||||
const settings = await getSettings();
|
||||
if (!settings.publicProfilesEnabled) notFound();
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { profileSlug: slug, publicProfile: true, isActive: true },
|
||||
select: { id: true, name: true, createdAt: true },
|
||||
});
|
||||
if (!user) notFound();
|
||||
|
||||
const [totals, mostWatched, topRated, recent] = await Promise.all([
|
||||
getTotals(user.id),
|
||||
getMostWatched(user.id, 6),
|
||||
getTopRated(user.id, 6),
|
||||
prisma.watch.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { watchedAt: "desc" },
|
||||
take: 8,
|
||||
distinct: ["titleId"],
|
||||
include: {
|
||||
title: { select: { id: true, title: true, posterPath: true, type: true } },
|
||||
episode: { select: { seasonNumber: true, episodeNumber: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-4 sm:px-6">
|
||||
<Link href="/" aria-label="Flimmer">
|
||||
<FlimmerLogo />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<main id="inhalt" className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
|
||||
<header className="fl-card relative overflow-hidden p-6 sm:p-8">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-20 -top-28 h-64 w-64 rounded-full opacity-25 blur-3xl"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Öffentliches Profil
|
||||
</p>
|
||||
<h1 className="mt-1 text-3xl font-semibold tracking-tight">{user.name}</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Dabei seit {user.createdAt.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||
</p>
|
||||
|
||||
<dl className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Stat label="Schauzeit" value={formatMinutes(totals.minutes)} />
|
||||
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
|
||||
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
|
||||
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
|
||||
</dl>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
|
||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
|
||||
{recent.map((watch) => (
|
||||
<li key={watch.id}>
|
||||
<Poster path={watch.title.posterPath} alt={watch.title.title} size="w185" />
|
||||
<p className="mt-1.5 line-clamp-2 text-xs font-medium leading-snug">
|
||||
{watch.title.title}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{formatRelative(watch.watchedAt)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
<TitleList heading="Die meiste Zeit" items={mostWatched} showMinutes />
|
||||
<TitleList heading="Bestbewertet" items={topRated} />
|
||||
</div>
|
||||
|
||||
{totals.minutes === 0 && (
|
||||
<p className="fl-card mt-6 p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||
Hier ist noch nichts zu sehen.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 text-center text-xs text-[var(--fl-faint)] sm:px-6">
|
||||
Erstellt mit Flimmer · Metadaten von TMDB
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleList({
|
||||
heading,
|
||||
items,
|
||||
showMinutes = false,
|
||||
}: {
|
||||
heading: string;
|
||||
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number }[];
|
||||
showMinutes?: boolean;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">{heading}</h2>
|
||||
<ol className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.titleId} className="flex items-center gap-3">
|
||||
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="w-10 shrink-0">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title}</span>
|
||||
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
|
||||
{showMinutes ? formatMinutes(item.minutes) : item.rating ? `${item.rating}/10` : "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { AdminSettings, UserRow } from "@/components/settings/Forms";
|
||||
import { formatRelative } from "@/lib/labels";
|
||||
|
||||
export const metadata: Metadata = { title: "Verwaltung" };
|
||||
|
||||
export default async function AdminPage() {
|
||||
const admin = await requireAdmin();
|
||||
const settings = await getSettings();
|
||||
|
||||
const [users, lastRun, catalog] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
_count: { select: { entries: true } },
|
||||
},
|
||||
}),
|
||||
prisma.syncRun.findFirst({ orderBy: { startedAt: "desc" } }),
|
||||
Promise.all([
|
||||
prisma.title.count(),
|
||||
prisma.episode.count(),
|
||||
prisma.watch.count(),
|
||||
]),
|
||||
]);
|
||||
|
||||
const [titleCount, episodeCount, watchCount] = catalog;
|
||||
|
||||
return (
|
||||
<AppShell user={admin} instanceName={settings.instanceName}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verwaltung</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Einstellungen der Instanz, Konten und Hintergrundlauf.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Instanz</h2>
|
||||
<AdminSettings
|
||||
registrationEnabled={settings.registrationEnabled}
|
||||
publicProfilesEnabled={settings.publicProfilesEnabled}
|
||||
instanceName={settings.instanceName}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Zustand</h2>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<Fact label="Konten" value={String(users.length)} />
|
||||
<Fact label="Titel im Katalog" value={titleCount.toLocaleString("de-DE")} />
|
||||
<Fact label="Episoden" value={episodeCount.toLocaleString("de-DE")} />
|
||||
<Fact label="Sichtungen" value={watchCount.toLocaleString("de-DE")} />
|
||||
<Fact
|
||||
label="TMDB"
|
||||
value={env.tmdb.isConfigured ? "eingerichtet" : "kein Schlüssel"}
|
||||
/>
|
||||
<Fact label="SMTP" value={env.smtp.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
||||
<Fact
|
||||
label="Zeitplan"
|
||||
value={
|
||||
env.scheduler.enabled
|
||||
? `täglich ${String(env.scheduler.hour).padStart(2, "0")}:05 Uhr`
|
||||
: "abgeschaltet"
|
||||
}
|
||||
/>
|
||||
<Fact label="OIDC" value={env.oidc.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-3 text-lg font-semibold">Letzter Abgleich</h2>
|
||||
{lastRun ? (
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
{formatRelative(lastRun.startedAt)} ·{" "}
|
||||
<span style={{ color: lastRun.ok ? "var(--fl-success)" : "var(--fl-danger)" }}>
|
||||
{lastRun.ok ? "erfolgreich" : "mit Fehlern"}
|
||||
</span>
|
||||
{lastRun.message ? ` · ${lastRun.message}` : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--fl-faint)]">Noch kein Abgleich gelaufen.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="fl-card mt-6 p-5">
|
||||
<h2 className="mb-2 text-lg font-semibold">Konten</h2>
|
||||
<ul className="divide-y divide-[var(--fl-border)]">
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
entries: user._count.entries,
|
||||
}}
|
||||
isSelf={user.id === admin.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||
<dd className="mt-0.5 font-medium">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Meldet den Service Worker an.
|
||||
*
|
||||
* Nur in der Produktion: im Entwicklungsmodus würde ein Zwischenspeicher
|
||||
* dauernd veraltete Bündel ausliefern und jede Änderung verschleiern.
|
||||
*/
|
||||
export function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const register = () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch((error) => {
|
||||
console.warn("Service Worker konnte nicht angemeldet werden:", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "complete") register();
|
||||
else window.addEventListener("load", register, { once: true });
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -19,15 +19,31 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
<div className="relative">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||
|
||||
<span
|
||||
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
|
||||
style={{
|
||||
color: STATUS_COLOR[status],
|
||||
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[status]}
|
||||
{/* 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="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
|
||||
style={{
|
||||
color: STATUS_COLOR[status],
|
||||
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
|
||||
{item.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 && (
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
changePasswordAction,
|
||||
createApiKeyAction,
|
||||
deleteChannelAction,
|
||||
importDataAction,
|
||||
revokeApiKeyAction,
|
||||
runSyncAction,
|
||||
saveChannelAction,
|
||||
setInstanceNameAction,
|
||||
setPublicProfilesAction,
|
||||
setRegistrationAction,
|
||||
setUserActiveAction,
|
||||
setUserRoleAction,
|
||||
testChannelAction,
|
||||
toggleChannelAction,
|
||||
updateProfileAction,
|
||||
} from "@/app/actions/settings";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
import { formatDate } from "@/lib/labels";
|
||||
|
||||
/** Wiederverwendete Bausteine der Einstellungsseiten. */
|
||||
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & { id: string; label: string; hint?: string }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
{label}
|
||||
</label>
|
||||
<input id={id} name={id} className="fl-input" {...props} />
|
||||
{hint && <p className="text-xs text-[var(--fl-faint)]">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
hint,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
"mt-0.5 h-5 w-9 shrink-0 rounded-full p-0.5 transition-colors",
|
||||
checked ? "" : "bg-[var(--fl-surface-2)] ring-1 ring-[var(--fl-border-strong)]",
|
||||
)}
|
||||
style={checked ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"block h-4 w-4 rounded-full bg-[var(--fl-text)] transition-transform",
|
||||
checked && "translate-x-4",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
{hint && <span className="block text-xs text-[var(--fl-faint)]">{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profil
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ProfileForm({
|
||||
user,
|
||||
publicProfilesEnabled,
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
region: string;
|
||||
publicProfile: boolean;
|
||||
profileSlug: string | null;
|
||||
notifyNewEpisode: boolean;
|
||||
notifyRelease: boolean;
|
||||
notifyAvailability: boolean;
|
||||
};
|
||||
publicProfilesEnabled: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [state, setState] = useState(user);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={() =>
|
||||
startTransition(async () => {
|
||||
report(
|
||||
await updateProfileAction({
|
||||
name: state.name,
|
||||
region: state.region,
|
||||
publicProfile: state.publicProfile,
|
||||
profileSlug: state.profileSlug || null,
|
||||
notifyNewEpisode: state.notifyNewEpisode,
|
||||
notifyRelease: state.notifyRelease,
|
||||
notifyAvailability: state.notifyAvailability,
|
||||
}),
|
||||
);
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="space-y-5"
|
||||
>
|
||||
<Field
|
||||
id="name"
|
||||
label="Anzeigename"
|
||||
value={state.name}
|
||||
onChange={(event) => setState({ ...state, name: event.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-[var(--fl-muted)]">E-Mail-Adresse</label>
|
||||
<p className="fl-input text-[var(--fl-faint)]">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
id="region"
|
||||
label="Region"
|
||||
value={state.region}
|
||||
maxLength={2}
|
||||
onChange={(event) => setState({ ...state, region: event.target.value.toUpperCase() })}
|
||||
hint="Zwei Buchstaben nach ISO 3166 — bestimmt, welche Streamingdienste angezeigt werden (DE, AT, CH …)."
|
||||
/>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-1 text-sm font-medium text-[var(--fl-muted)]">
|
||||
Wovon soll Flimmer dir berichten?
|
||||
</legend>
|
||||
<Toggle
|
||||
checked={state.notifyNewEpisode}
|
||||
onChange={(value) => setState({ ...state, notifyNewEpisode: value })}
|
||||
label="Neue Episoden"
|
||||
hint="Wenn bei einer Serie in deiner Bibliothek eine Folge dazukommt."
|
||||
/>
|
||||
<Toggle
|
||||
checked={state.notifyRelease}
|
||||
onChange={(value) => setState({ ...state, notifyRelease: value })}
|
||||
label="Neue Releases"
|
||||
hint="Kinostart oder Heimkinostart eines Films auf deiner Watchlist."
|
||||
/>
|
||||
<Toggle
|
||||
checked={state.notifyAvailability}
|
||||
onChange={(value) => setState({ ...state, notifyAvailability: value })}
|
||||
label="Neu verfügbar"
|
||||
hint="Wenn ein Watchlist-Titel auf einem Dienst in deiner Region auftaucht."
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{publicProfilesEnabled && (
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="mb-1 text-sm font-medium text-[var(--fl-muted)]">
|
||||
Öffentliches Profil
|
||||
</legend>
|
||||
<Toggle
|
||||
checked={state.publicProfile}
|
||||
onChange={(value) => setState({ ...state, publicProfile: value })}
|
||||
label="Profil öffentlich zeigen"
|
||||
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
|
||||
/>
|
||||
{state.publicProfile && (
|
||||
<Field
|
||||
id="profileSlug"
|
||||
label="Adresse"
|
||||
value={state.profileSlug ?? ""}
|
||||
onChange={(event) => setState({ ...state, profileSlug: event.target.value })}
|
||||
placeholder="dein-name"
|
||||
hint="Erreichbar unter /u/<adresse>"
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
{pending ? "Einen Moment …" : "Speichern"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasswordForm() {
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(formData) =>
|
||||
startTransition(async () => {
|
||||
const result = await changePasswordAction({
|
||||
current: String(formData.get("current") ?? ""),
|
||||
next: String(formData.get("next") ?? ""),
|
||||
repeat: String(formData.get("repeat") ?? ""),
|
||||
});
|
||||
report(result);
|
||||
if (result.ok) window.location.href = "/anmelden";
|
||||
})
|
||||
}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field id="current" label="Bisheriges Passwort" type="password" autoComplete="current-password" required />
|
||||
<Field
|
||||
id="next"
|
||||
label="Neues Passwort"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
hint="Mindestens 10 Zeichen."
|
||||
/>
|
||||
<Field id="repeat" label="Neues Passwort wiederholen" type="password" autoComplete="new-password" required />
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Nach dem Wechsel wirst du auf allen Geräten abgemeldet.
|
||||
</p>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost">
|
||||
Passwort ändern
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API-Schlüssel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ApiKeys({
|
||||
keys,
|
||||
}: {
|
||||
keys: { id: string; name: string; prefix: string; lastUsedAt: Date | null; createdAt: Date }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<form
|
||||
action={(formData) => {
|
||||
const name = String(formData.get("keyName") ?? "").trim();
|
||||
if (!name) return;
|
||||
startTransition(async () => {
|
||||
const result = await createApiKeyAction(name);
|
||||
report(result);
|
||||
if (result.secret) setSecret(result.secret);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
>
|
||||
<div className="min-w-48 flex-1 space-y-1.5">
|
||||
<label htmlFor="keyName" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Neuer Schlüssel
|
||||
</label>
|
||||
<input id="keyName" name="keyName" placeholder="z. B. Handy-Skript" className="fl-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{secret && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border p-3"
|
||||
style={{
|
||||
borderColor: "color-mix(in oklab, var(--fl-warning) 45%, transparent)",
|
||||
backgroundColor: "color-mix(in oklab, var(--fl-warning) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium text-[var(--fl-warning)]">
|
||||
Einmalig sichtbar — jetzt kopieren:
|
||||
</p>
|
||||
<code className="mt-2 block break-all rounded-lg bg-[var(--fl-surface-2)] p-2 font-mono text-xs">
|
||||
{secret}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(secret);
|
||||
report({ ok: true, message: "In die Zwischenablage kopiert" });
|
||||
}}
|
||||
className="fl-btn fl-btn-ghost mt-2 px-3 py-1.5 text-sm"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">Noch kein Schlüssel angelegt.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--fl-border)]">
|
||||
{keys.map((key) => (
|
||||
<li key={key.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{key.name}</p>
|
||||
<p className="font-mono text-xs text-[var(--fl-faint)]">
|
||||
{key.prefix}… · angelegt {formatDate(key.createdAt)}
|
||||
{key.lastUsedAt ? ` · zuletzt benutzt ${formatDate(key.lastUsedAt)}` : " · nie benutzt"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await revokeApiKeyAction(key.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
Zurückziehen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benachrichtigungskanäle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHANNEL_FIELDS: Record<string, { key: string; label: string; placeholder: string }[]> = {
|
||||
EMAIL: [{ key: "email", label: "Abweichende Adresse", placeholder: "leer = Konto-Adresse" }],
|
||||
NTFY: [
|
||||
{ key: "server", label: "Server", placeholder: "https://ntfy.sh" },
|
||||
{ key: "topic", label: "Topic", placeholder: "flimmer-abc123" },
|
||||
{ key: "token", label: "Zugriffstoken", placeholder: "optional" },
|
||||
],
|
||||
GOTIFY: [
|
||||
{ key: "url", label: "Server", placeholder: "https://gotify.example.com" },
|
||||
{ key: "token", label: "Anwendungs-Token", placeholder: "AxB..." },
|
||||
],
|
||||
WEBHOOK: [
|
||||
{ key: "webhookUrl", label: "Adresse", placeholder: "https://discord.com/api/webhooks/…" },
|
||||
],
|
||||
};
|
||||
|
||||
const CHANNEL_LABEL: Record<string, string> = {
|
||||
EMAIL: "E-Mail",
|
||||
NTFY: "ntfy",
|
||||
GOTIFY: "Gotify",
|
||||
WEBHOOK: "Webhook (Discord, Slack, …)",
|
||||
};
|
||||
|
||||
export function ChannelList({
|
||||
channels,
|
||||
smtpReady,
|
||||
}: {
|
||||
channels: { id: string; kind: string; name: string; config: Record<string, string>; enabled: boolean }[];
|
||||
smtpReady: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [kind, setKind] = useState<string>("NTFY");
|
||||
const [config, setConfig] = useState<Record<string, string>>({});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{channels.length > 0 && (
|
||||
<ul className="divide-y divide-[var(--fl-border)]">
|
||||
{channels.map((channel) => (
|
||||
<li key={channel.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{channel.name}
|
||||
<span className="ml-2 text-xs font-normal text-[var(--fl-faint)]">
|
||||
{CHANNEL_LABEL[channel.kind] ?? channel.kind}
|
||||
</span>
|
||||
</p>
|
||||
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||
{Object.entries(channel.config)
|
||||
.filter(([key]) => key !== "token")
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(" · ") || "ohne weitere Angaben"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
checked={channel.enabled}
|
||||
onChange={(value) =>
|
||||
startTransition(async () => {
|
||||
report(await toggleChannelAction(channel.id, value));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await testChannelAction(channel.id));
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
>
|
||||
Testen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await deleteChannelAction(channel.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form
|
||||
action={(formData) => {
|
||||
const name = String(formData.get("channelName") ?? "").trim();
|
||||
if (!name) return;
|
||||
startTransition(async () => {
|
||||
report(await saveChannelAction({ kind, name, config }));
|
||||
setConfig({});
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className="space-y-4 border-t border-[var(--fl-border)] pt-5"
|
||||
>
|
||||
<h3 className="text-sm font-semibold">Kanal hinzufügen</h3>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="kind" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Art
|
||||
</label>
|
||||
<select
|
||||
id="kind"
|
||||
value={kind}
|
||||
onChange={(event) => {
|
||||
setKind(event.target.value);
|
||||
setConfig({});
|
||||
}}
|
||||
className="fl-input"
|
||||
>
|
||||
{Object.entries(CHANNEL_LABEL).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{kind === "EMAIL" && !smtpReady && (
|
||||
<p className="text-xs text-[var(--fl-warning)]">
|
||||
Für diese Instanz ist kein SMTP-Zugang eingerichtet — E-Mails können nicht verschickt
|
||||
werden.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field id="channelName" label="Name" placeholder="z. B. Handy" required />
|
||||
|
||||
{(CHANNEL_FIELDS[kind] ?? []).map((field) => (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<label
|
||||
htmlFor={`cfg-${field.key}`}
|
||||
className="block text-sm font-medium text-[var(--fl-muted)]"
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
id={`cfg-${field.key}`}
|
||||
value={config[field.key] ?? ""}
|
||||
onChange={(event) => setConfig({ ...config, [field.key]: event.target.value })}
|
||||
placeholder={field.placeholder}
|
||||
className="fl-input"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
Kanal anlegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ImportForm() {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [format, setFormat] = useState<"csv" | "trakt">("csv");
|
||||
const [summary, setSummary] = useState<string[] | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{(["csv", "trakt"] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setFormat(option)}
|
||||
aria-pressed={format === option}
|
||||
className="rounded-full border px-4 py-2 text-sm font-medium transition-colors"
|
||||
style={
|
||||
format === option
|
||||
? {
|
||||
backgroundImage: "var(--fl-gradient)",
|
||||
color: "var(--fl-on-accent)",
|
||||
borderColor: "transparent",
|
||||
}
|
||||
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
|
||||
}
|
||||
>
|
||||
{option === "csv" ? "CSV" : "Trakt.tv (JSON)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
{format === "csv"
|
||||
? "Die Kopfzeile braucht mindestens eine Spalte mit dem Titel. Erkannt werden außerdem Typ, Status, Bewertung, TMDB, Jahr und Notiz."
|
||||
: "Ein Export aus Trakt.tv — Watchlist, History, Ratings oder Collection. Flimmer sucht selbst die Einträge heraus."}
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept={format === "csv" ? ".csv,text/csv" : ".json,application/json"}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setSummary(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const content = await file.text();
|
||||
const result = await importDataAction(format, content);
|
||||
report({ ok: result.ok, message: result.message });
|
||||
|
||||
if (result.summary) {
|
||||
setSummary([
|
||||
`${result.summary.processed} Zeilen gelesen`,
|
||||
`${result.summary.added} neu aufgenommen`,
|
||||
`${result.summary.skipped} übersprungen (schon vorhanden oder unvollständig)`,
|
||||
...result.summary.errors.slice(0, 5),
|
||||
]);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
disabled={pending}
|
||||
className="fl-input file:mr-3 file:rounded-lg file:border-0 file:bg-[var(--fl-surface-2)] file:px-3 file:py-1.5 file:text-sm file:text-[var(--fl-text)]"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Der Import überschreibt nichts: Titel, die schon in deiner Bibliothek liegen, bleiben
|
||||
unangetastet.
|
||||
</p>
|
||||
|
||||
{summary && (
|
||||
<ul className="rounded-xl border border-[var(--fl-border)] p-3 text-sm text-[var(--fl-muted)]">
|
||||
{summary.map((line, index) => (
|
||||
<li key={index}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verwaltung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AdminSettings({
|
||||
registrationEnabled,
|
||||
publicProfilesEnabled,
|
||||
instanceName,
|
||||
}: {
|
||||
registrationEnabled: boolean;
|
||||
publicProfilesEnabled: boolean;
|
||||
instanceName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<form
|
||||
action={(formData) =>
|
||||
startTransition(async () => {
|
||||
report(await setInstanceNameAction(String(formData.get("instanceName") ?? "")));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
>
|
||||
<div className="min-w-48 flex-1">
|
||||
<Field id="instanceName" label="Name der Instanz" defaultValue={instanceName} />
|
||||
</div>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost">
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Toggle
|
||||
checked={registrationEnabled}
|
||||
onChange={(value) =>
|
||||
startTransition(async () => {
|
||||
report(await setRegistrationAction(value));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
label="Registrierung offen"
|
||||
hint="Solange es kein einziges Konto gibt, bleibt die Registrierung unabhängig davon offen."
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
checked={publicProfilesEnabled}
|
||||
onChange={(value) =>
|
||||
startTransition(async () => {
|
||||
report(await setPublicProfilesAction(value));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
label="Öffentliche Profile erlauben"
|
||||
hint="Schaltet die Profilseiten instanzweit ab, unabhängig von der Einstellung der Nutzer."
|
||||
/>
|
||||
|
||||
<div className="border-t border-[var(--fl-border)] pt-5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await runSyncAction());
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-primary"
|
||||
>
|
||||
{pending ? "Läuft …" : "Abgleich jetzt starten"}
|
||||
</button>
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
Frischt laufende Serien bei TMDB auf, verschickt fällige Benachrichtigungen und räumt den
|
||||
Papierkorb. Kann eine Weile dauern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserRow({
|
||||
user,
|
||||
isSelf,
|
||||
}: {
|
||||
user: { id: string; name: string; email: string; role: string; isActive: boolean; entries: number };
|
||||
isSelf: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{user.name}
|
||||
{user.role === "ADMIN" && (
|
||||
<span
|
||||
className="ml-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
>
|
||||
Verwaltung
|
||||
</span>
|
||||
)}
|
||||
{!user.isActive && (
|
||||
<span className="ml-2 text-[0.65rem] font-semibold text-[var(--fl-danger)]">gesperrt</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||
{user.email} · {user.entries} {user.entries === 1 ? "Titel" : "Titel"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isSelf && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await setUserRoleAction(user.id, user.role === "ADMIN" ? "USER" : "ADMIN"));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
>
|
||||
{user.role === "ADMIN" ? "Rechte entziehen" : "Zur Verwaltung"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await setUserActiveAction(user.id, !user.isActive));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
|
||||
>
|
||||
{user.isActive ? "Sperren" : "Freigeben"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/** Seitennavigation der Einstellungen. */
|
||||
const ITEMS = [
|
||||
{ href: "/einstellungen", label: "Profil" },
|
||||
{ href: "/einstellungen/benachrichtigungen", label: "Benachrichtigungen" },
|
||||
{ href: "/einstellungen/api", label: "API-Schlüssel" },
|
||||
{ href: "/einstellungen/daten", label: "Import und Export" },
|
||||
];
|
||||
|
||||
export function SettingsNav({ isAdmin }: { isAdmin: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Einstellungen"
|
||||
className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible"
|
||||
>
|
||||
{ITEMS.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={clsx(
|
||||
"shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-[var(--fl-surface-2)] text-[var(--fl-text)]"
|
||||
: "text-[var(--fl-muted)] hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/verwaltung"
|
||||
className="shrink-0 rounded-lg px-3 py-2 text-sm font-medium text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
|
||||
>
|
||||
Verwaltung
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Wird von Next beim Start des Servers einmal ausgeführt.
|
||||
*
|
||||
* Hier hängt der eingebaute Zeitplan für den täglichen Abgleich. Er läuft nur
|
||||
* in der Node-Laufzeit (nicht im Edge-Runtime) und nur, wenn ENABLE_SCHEDULER
|
||||
* gesetzt ist — bei mehreren App-Instanzen soll ihn nur eine übernehmen.
|
||||
*/
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
const { startScheduler } = await import("./lib/sync");
|
||||
startScheduler();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import "server-only";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getApiUser, type SessionUser } from "./auth";
|
||||
import { LibraryError } from "./library";
|
||||
import { UniverseError } from "./universes";
|
||||
|
||||
/**
|
||||
* Gemeinsames Gerüst der REST-API.
|
||||
*
|
||||
* Zwei Dinge macht jeder Endpunkt gleich: die Anmeldung prüfen (Bearer-Token
|
||||
* oder Sitzungscookie — dieselben Endpunkte bedienen Skripte und das eigene
|
||||
* Frontend) und Fehler in eine einheitliche Form bringen.
|
||||
*/
|
||||
|
||||
export type ApiHandler<T = unknown> = (
|
||||
request: Request,
|
||||
user: SessionUser,
|
||||
context: { params: Promise<Record<string, string>> },
|
||||
) => Promise<T>;
|
||||
|
||||
export function apiError(message: string, status = 400, details?: unknown) {
|
||||
return NextResponse.json({ error: message, ...(details ? { details } : {}) }, { status });
|
||||
}
|
||||
|
||||
/**
|
||||
* Umhüllt einen Endpunkt: Anmeldung, Fehlerbehandlung, JSON-Antwort.
|
||||
* Gibt der Handler bereits eine Response zurück, wird sie durchgereicht.
|
||||
*/
|
||||
export function withApi<T>(handler: ApiHandler<T>) {
|
||||
return async (request: Request, context: { params: Promise<Record<string, string>> }) => {
|
||||
const user = await getApiUser(request);
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Nicht angemeldet. Schicke einen API-Schlüssel im Kopf: Authorization: Bearer <schlüssel>",
|
||||
},
|
||||
{ status: 401, headers: { "www-authenticate": "Bearer" } },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(request, user, context);
|
||||
if (result instanceof NextResponse) return result;
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return apiError("Die Anfrage ist unvollständig oder falsch aufgebaut.", 422, error.issues);
|
||||
}
|
||||
if (error instanceof LibraryError || error instanceof UniverseError) {
|
||||
return apiError(error.message, 400);
|
||||
}
|
||||
console.error("API-Fehler:", error);
|
||||
return apiError("Interner Fehler.", 500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest und prüft den JSON-Rumpf einer Anfrage. */
|
||||
export async function readJson<S extends z.ZodTypeAny>(
|
||||
request: Request,
|
||||
schema: S,
|
||||
): Promise<z.infer<S>> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
throw new z.ZodError([
|
||||
{
|
||||
code: "custom",
|
||||
path: [],
|
||||
message: "Der Rumpf der Anfrage ist kein gültiges JSON.",
|
||||
},
|
||||
]);
|
||||
}
|
||||
return schema.parse(raw);
|
||||
}
|
||||
|
||||
/** Seitenweise Abfrage: `?limit=50&offset=0` */
|
||||
export function readPaging(request: Request, defaultLimit = 50, maxLimit = 200) {
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
maxLimit,
|
||||
Math.max(1, Number.parseInt(url.searchParams.get("limit") ?? "", 10) || defaultLimit),
|
||||
);
|
||||
const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "", 10) || 0);
|
||||
return { limit, offset };
|
||||
}
|
||||
@@ -49,6 +49,12 @@ export const env = {
|
||||
|
||||
tmdb: {
|
||||
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"),
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import "server-only";
|
||||
import nodemailer from "nodemailer";
|
||||
import { prisma } from "./db";
|
||||
import { env } from "./env";
|
||||
import type { ChannelKind } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Benachrichtigungen.
|
||||
*
|
||||
* Vier Wege, ein Nachrichtenformat: E-Mail, ntfy, Gotify und ein generischer
|
||||
* Webhook (der auch Discord und Slack bedient). Jeder Nutzer richtet sich
|
||||
* seine Kanäle selbst ein — der Betreiber gibt nur den SMTP-Zugang vor.
|
||||
*
|
||||
* Doppelversand verhindert `NotificationLog` über einen Dedupe-Schlüssel:
|
||||
* dieselbe Episode wird nie zweimal gemeldet, egal wie oft der Abgleich läuft.
|
||||
*/
|
||||
|
||||
export type NotificationMessage = {
|
||||
/** Kurze Betreffzeile. */
|
||||
subject: string;
|
||||
/** Fließtext, eine bis drei Zeilen. */
|
||||
body: string;
|
||||
/** Ziel in Flimmer, z. B. „/titel/abc". */
|
||||
path?: string;
|
||||
/** Verhindert Doppelversand, z. B. „episode:<id>". */
|
||||
dedupeKey: string;
|
||||
};
|
||||
|
||||
export type ChannelConfig = {
|
||||
/** ntfy: Server-URL, Standard https://ntfy.sh */
|
||||
server?: string;
|
||||
/** ntfy: Topic; Gotify/Webhook: nicht benutzt */
|
||||
topic?: string;
|
||||
/** Gotify: Server-URL */
|
||||
url?: string;
|
||||
/** Gotify: Anwendungs-Token; ntfy: optionaler Zugriffstoken */
|
||||
token?: string;
|
||||
/** Webhook: Zieladresse */
|
||||
webhookUrl?: string;
|
||||
/** E-Mail: abweichende Empfängeradresse */
|
||||
email?: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Versandwege
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function linkFor(message: NotificationMessage): string | null {
|
||||
return message.path ? `${env.appUrl}${message.path}` : null;
|
||||
}
|
||||
|
||||
async function sendEmail(to: string, message: NotificationMessage) {
|
||||
if (!env.smtp.isConfigured) {
|
||||
throw new Error("Für diese Instanz ist kein SMTP-Zugang eingerichtet.");
|
||||
}
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host: env.smtp.host,
|
||||
port: env.smtp.port,
|
||||
secure: env.smtp.secure,
|
||||
...(env.smtp.user ? { auth: { user: env.smtp.user, pass: env.smtp.password } } : {}),
|
||||
});
|
||||
|
||||
const link = linkFor(message);
|
||||
await transport.sendMail({
|
||||
from: env.smtp.from,
|
||||
to,
|
||||
subject: `${message.subject} · ${env.appName}`,
|
||||
text: [message.body, link].filter(Boolean).join("\n\n"),
|
||||
html: emailTemplate(message, link),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* E-Mail-Vorlage im Flimmer-Look.
|
||||
*
|
||||
* Bewusst schlichtes HTML mit Inline-Stilen — E-Mail-Programme können weder
|
||||
* externe Stylesheets noch CSS-Variablen. Das Logo steckt als Inline-SVG
|
||||
* drin, damit nichts nachgeladen werden muss.
|
||||
*/
|
||||
function emailTemplate(message: NotificationMessage, link: string | null): string {
|
||||
const escape = (value: string) =>
|
||||
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de"><body style="margin:0;background:#0c0a14;padding:24px;font-family:'Segoe UI',Inter,Helvetica,Arial,sans-serif;color:#f4f1fa">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width:520px;margin:0 auto;background:#171424;border:1px solid #2e2745;border-radius:16px">
|
||||
<tr><td style="padding:24px 24px 8px">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
||||
<td style="padding-right:10px">
|
||||
<svg width="30" height="30" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs><linearGradient id="g" x1="20" y1="24" x2="108" y2="102" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ffc46b"/><stop offset=".38" stop-color="#ffb454"/><stop offset="1" stop-color="#ff4d9d"/>
|
||||
</linearGradient></defs>
|
||||
<rect x="22" y="26" width="84" height="64" rx="18" fill="none" stroke="url(#g)" stroke-width="7"/>
|
||||
<path d="M55.5 45.5 L80 58 L55.5 70.5 Z" fill="url(#g)" stroke="url(#g)" stroke-width="6.5" stroke-linejoin="round"/>
|
||||
<path d="M64 90 V101 M49 105.5 H79" stroke="url(#g)" stroke-width="6.5" stroke-linecap="round" stroke-opacity=".62"/>
|
||||
</svg>
|
||||
</td>
|
||||
<td style="font-size:20px;font-weight:700;letter-spacing:-.01em;color:#ffb454">Flimmer</td>
|
||||
</tr></table>
|
||||
</td></tr>
|
||||
<tr><td style="padding:8px 24px 4px;font-size:18px;font-weight:600">${escape(message.subject)}</td></tr>
|
||||
<tr><td style="padding:4px 24px 20px;font-size:15px;line-height:1.55;color:#aca5c2">${escape(message.body).replace(/\n/g, "<br>")}</td></tr>
|
||||
${
|
||||
link
|
||||
? `<tr><td style="padding:0 24px 24px">
|
||||
<a href="${link}" style="display:inline-block;background:#ffb454;color:#1a1024;text-decoration:none;font-weight:600;padding:10px 18px;border-radius:10px">In Flimmer ansehen</a>
|
||||
</td></tr>`
|
||||
: ""
|
||||
}
|
||||
<tr><td style="padding:0 24px 22px;font-size:12px;color:#7c7594">
|
||||
Diese Nachricht kommt von deiner eigenen Flimmer-Instanz. Kanäle verwaltest du in den Einstellungen.
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function sendNtfy(config: ChannelConfig, message: NotificationMessage) {
|
||||
const server = (config.server || "https://ntfy.sh").replace(/\/+$/, "");
|
||||
if (!config.topic) throw new Error("Für ntfy fehlt das Topic.");
|
||||
|
||||
const link = linkFor(message);
|
||||
const headers: Record<string, string> = {
|
||||
Title: message.subject,
|
||||
Tags: "clapper",
|
||||
};
|
||||
if (link) headers.Click = link;
|
||||
if (config.token) headers.Authorization = `Bearer ${config.token}`;
|
||||
|
||||
const response = await fetch(`${server}/${encodeURIComponent(config.topic)}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: message.body,
|
||||
});
|
||||
if (!response.ok) throw new Error(`ntfy antwortete mit ${response.status}.`);
|
||||
}
|
||||
|
||||
async function sendGotify(config: ChannelConfig, message: NotificationMessage) {
|
||||
if (!config.url || !config.token) throw new Error("Für Gotify fehlen Adresse oder Token.");
|
||||
const base = config.url.replace(/\/+$/, "");
|
||||
const link = linkFor(message);
|
||||
|
||||
const response = await fetch(`${base}/message?token=${encodeURIComponent(config.token)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: message.subject,
|
||||
message: [message.body, link].filter(Boolean).join("\n\n"),
|
||||
priority: 4,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Gotify antwortete mit ${response.status}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generischer Webhook.
|
||||
*
|
||||
* Der Rumpf trägt sowohl `content` (Discord) als auch `text` (Slack und
|
||||
* Mattermost) und daneben die Einzelteile — damit funktioniert derselbe Kanal
|
||||
* für alle drei, ohne dass jemand ein Format auswählen muss.
|
||||
*/
|
||||
async function sendWebhook(config: ChannelConfig, message: NotificationMessage) {
|
||||
if (!config.webhookUrl) throw new Error("Für den Webhook fehlt die Adresse.");
|
||||
|
||||
const link = linkFor(message);
|
||||
const text = [`**${message.subject}**`, message.body, link].filter(Boolean).join("\n");
|
||||
|
||||
const response = await fetch(config.webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
text,
|
||||
username: env.appName,
|
||||
subject: message.subject,
|
||||
body: message.body,
|
||||
url: link,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Der Webhook antwortete mit ${response.status}.`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Versand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function sendThroughChannel(
|
||||
kind: ChannelKind,
|
||||
config: ChannelConfig,
|
||||
fallbackEmail: string,
|
||||
message: NotificationMessage,
|
||||
): Promise<void> {
|
||||
switch (kind) {
|
||||
case "EMAIL":
|
||||
return sendEmail(config.email || fallbackEmail, message);
|
||||
case "NTFY":
|
||||
return sendNtfy(config, message);
|
||||
case "GOTIFY":
|
||||
return sendGotify(config, message);
|
||||
case "WEBHOOK":
|
||||
return sendWebhook(config, message);
|
||||
}
|
||||
}
|
||||
|
||||
export type NotifyResult = { sent: number; skipped: number; failed: number };
|
||||
|
||||
/**
|
||||
* Schickt eine Nachricht über alle aktiven Kanäle eines Nutzers.
|
||||
* Bereits gemeldete Nachrichten (gleicher Dedupe-Schlüssel) werden übersprungen.
|
||||
*/
|
||||
export async function notifyUser(
|
||||
userId: string,
|
||||
message: NotificationMessage,
|
||||
): Promise<NotifyResult> {
|
||||
const existing = await prisma.notificationLog.findUnique({
|
||||
where: { userId_dedupeKey: { userId, dedupeKey: message.dedupeKey } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return { sent: 0, skipped: 1, failed: 0 };
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, channels: { where: { enabled: true } } },
|
||||
});
|
||||
if (!user || user.channels.length === 0) return { sent: 0, skipped: 1, failed: 0 };
|
||||
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const channel of user.channels) {
|
||||
try {
|
||||
await sendThroughChannel(
|
||||
channel.kind,
|
||||
(channel.config ?? {}) as ChannelConfig,
|
||||
user.email,
|
||||
message,
|
||||
);
|
||||
sent += 1;
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
errors.push(`${channel.name}: ${error instanceof Error ? error.message : "unbekannt"}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Auch fehlgeschlagene Versuche werden protokolliert — sonst versucht es der
|
||||
// nächste Lauf endlos erneut und der Kanal wird zugespammt, sobald er wieder
|
||||
// erreichbar ist.
|
||||
await prisma.notificationLog.create({
|
||||
data: {
|
||||
userId,
|
||||
dedupeKey: message.dedupeKey,
|
||||
subject: message.subject,
|
||||
body: message.body,
|
||||
ok: failed === 0,
|
||||
error: errors.length > 0 ? errors.join(" | ") : null,
|
||||
},
|
||||
});
|
||||
|
||||
return { sent, skipped: 0, failed };
|
||||
}
|
||||
|
||||
/** Testnachricht für die Einstellungsseite. */
|
||||
export async function sendTestNotification(userId: string, channelId: string): Promise<void> {
|
||||
const channel = await prisma.notificationChannel.findFirst({
|
||||
where: { id: channelId, userId },
|
||||
include: { user: { select: { email: true, name: true } } },
|
||||
});
|
||||
if (!channel) throw new Error("Diesen Kanal gibt es nicht.");
|
||||
|
||||
await sendThroughChannel(
|
||||
channel.kind,
|
||||
(channel.config ?? {}) as ChannelConfig,
|
||||
channel.user.email,
|
||||
{
|
||||
subject: "Testnachricht von Flimmer",
|
||||
body: `Hallo ${channel.user.name}, dieser Kanal funktioniert. Ab jetzt meldet sich Flimmer hier, wenn eine neue Episode ansteht.`,
|
||||
path: "/einstellungen/benachrichtigungen",
|
||||
dedupeKey: `test:${channelId}:${Date.now()}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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] } } });
|
||||
});
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||