Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3671fbdc40 | ||
|
|
0f9ad6e29d | ||
|
|
2deb7e1aa5 | ||
|
|
dd2b7dd48b | ||
|
|
824cc7764c | ||
|
|
c0579c6f61 | ||
|
|
58a1d177df | ||
|
|
d04248bec9 | ||
|
|
605afe556d | ||
|
|
39098cd201 | ||
|
|
fd1ab2918a | ||
|
|
40fb49fb21 | ||
|
|
5673055216 | ||
|
|
853d56f764 | ||
|
|
9d4e419cd1 | ||
|
|
a853b066b5 | ||
|
|
4ad5b244bc | ||
|
|
19f8003945 | ||
|
|
9ee8664233 | ||
|
|
cd9953c47c | ||
|
|
18c87e6b2e | ||
|
|
afa3d74248 | ||
|
|
b3eb946cfa | ||
|
|
29ef710b9b | ||
|
|
3db6241e9a | ||
|
|
d8239441aa | ||
|
|
96a42594a9 | ||
|
|
a54e625ca8 | ||
|
|
ff9d380d42 | ||
|
|
8d6f1d044a | ||
|
|
cf38bd6854 | ||
|
|
0adcccc01a | ||
|
|
f02e3597b9 | ||
|
|
a9314acbb4 | ||
|
|
bb63827bf7 | ||
|
|
b25f0473fd | ||
|
|
969353ab32 | ||
|
|
f49ac17922 | ||
|
|
7ac598ea81 | ||
|
|
075b42584a | ||
|
|
8c872b240a | ||
|
|
0e51df7bad | ||
|
|
b53ee828ba | ||
|
|
ed6011384a | ||
|
|
5cf4fc48a6 | ||
|
|
5ee6946fd5 | ||
|
|
90497ddc3b | ||
|
|
190d7b3796 | ||
|
|
e2565b600f | ||
|
|
a3c74c157b | ||
|
|
6f5bdcc20c | ||
|
|
f9ce2f7021 | ||
|
|
f613136e8a | ||
|
|
4c3c878980 | ||
|
|
bb6ce4ab35 | ||
|
|
9ecaaf03a9 | ||
|
|
75128d1bf2 | ||
|
|
e11cb6abef | ||
|
|
cc0e498fe6 | ||
|
|
4feb84c060 |
@@ -4,6 +4,7 @@ node_modules
|
||||
.env
|
||||
.env.local
|
||||
data
|
||||
backups
|
||||
docs/screenshots
|
||||
*.log
|
||||
test-results
|
||||
|
||||
@@ -27,6 +27,9 @@ AUTH_SECRET=bitte-durch-zufallswert-ersetzen
|
||||
# Ablage für Bild-Cache und sonstige Laufzeitdaten.
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# Port, auf dem Flimmer auf dem Host lauscht.
|
||||
APP_PORT=3000
|
||||
|
||||
# Zeitzone für Kalender, Statistiken und geplante Läufe
|
||||
TZ=Europe/Berlin
|
||||
|
||||
@@ -45,6 +48,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
|
||||
@@ -65,6 +71,13 @@ SMTP_PASSWORD=
|
||||
SMTP_SECURE=false
|
||||
SMTP_FROM=Flimmer <flimmer@example.com>
|
||||
|
||||
# --- Passkeys (WebAuthn) -----------------------------------------------------
|
||||
# Anmeldung per Fingerabdruck, Gesicht oder Sicherheitsschlüssel. Standardmäßig an.
|
||||
# Passkeys brauchen HTTPS — Ausnahme ist localhost. Sie sind fest an den
|
||||
# Hostnamen aus APP_URL gebunden: nach einem Domainwechsel müssen alle
|
||||
# Passkeys neu angelegt werden.
|
||||
ENABLE_PASSKEYS=true
|
||||
|
||||
# --- OIDC / SSO (optional) ---------------------------------------------------
|
||||
# Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert.
|
||||
# Redirect-URI beim Anbieter eintragen: <APP_URL>/api/auth/oidc/callback
|
||||
|
||||
@@ -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"
|
||||
@@ -11,6 +11,7 @@ next-env.d.ts
|
||||
|
||||
# Produktion / Laufzeit
|
||||
data/
|
||||
backups/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Umgebung — niemals committen!
|
||||
@@ -42,3 +43,4 @@ Thumbs.db
|
||||
/blob-report/
|
||||
.playwright/
|
||||
src/generated/
|
||||
.env.composetest
|
||||
|
||||
@@ -7,6 +7,422 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.8.0] — 2026-09-02
|
||||
|
||||
Zwei größere Bausteine: **Anmeldung per Passkey** und die **Jellyfin-Anbindung**.
|
||||
Dazu vier Nacharbeiten aus dem laufenden Betrieb.
|
||||
|
||||
> ⚠️ **Beim Aktualisieren kommen zwei Migrationen mit** (`passkeys`,
|
||||
> `medienserver`). Der Container spielt sie beim Start selbst ein; `./update.sh`
|
||||
> legt vorher eine Sicherung an.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Anmeldung per Passkey (WebAuthn).** Windows Hello, Face ID, Touch ID oder
|
||||
ein Sicherheitsschlüssel — statt Passwort. Anlegen und verwalten unter
|
||||
*Einstellungen → Passkeys*, anmelden über „Mit Passkey anmelden" **ohne
|
||||
vorherige E-Mail-Eingabe**.
|
||||
|
||||
Ein Passkey ist der dritte Weg in denselben Trichter: am Ende steht dieselbe
|
||||
Sitzung wie bei Passwort und OIDC. Das Passwort bleibt daneben bestehen, und
|
||||
den letzten verbleibenden Anmeldeweg lässt Flimmer nicht entfernen.
|
||||
|
||||
Drei Dinge, die beim Selbsthosten zählen:
|
||||
- **HTTPS ist Pflicht** (Ausnahme: `localhost`). Läuft die Instanz über
|
||||
einfaches HTTP, verschwindet die Verwaltung mit einem Hinweis, statt einen
|
||||
kryptischen Browserfehler zu zeigen.
|
||||
- Der Hostname kommt aus `APP_URL`, nicht aus dem `Host`-Header — hinter
|
||||
einem Reverse Proxy hinge er sonst am falschen Namen.
|
||||
- **Ein Domainwechsel entwertet alle Passkeys.** Sie sind fest an den
|
||||
Hostnamen gebunden und müssen danach neu angelegt werden.
|
||||
|
||||
Abschaltbar über `ENABLE_PASSKEYS`.
|
||||
|
||||
- **Jellyfin anbinden** — Bestand und Sichtungen übernehmen, unter
|
||||
*Einstellungen → Medienserver*.
|
||||
|
||||
Zugeordnet wird über die **TMDB-Kennung**, die Jellyfin zu jedem Item
|
||||
mitliefert; dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut. Kein
|
||||
Raten über Titel und Jahreszahlen — die Zuordnung ist eindeutig oder sie
|
||||
findet nicht statt. Was Jellyfin ohne Kennung führt, landet im Bericht.
|
||||
|
||||
Angemeldet wird mit Benutzername und Passwort, **nicht** mit einem
|
||||
API-Schlüssel: mit einem Schlüssel liefert Jellyfin den Bestand ohne
|
||||
Gesehen-Status, und zwar ohne Fehlermeldung. Der Verbindungstest prüft das
|
||||
ausdrücklich nach und sagt es. Das Zugangstoken liegt verschlüsselt in der
|
||||
Datenbank (AES-256-GCM, Schlüssel aus `AUTH_SECRET`), das Passwort wird nicht
|
||||
gespeichert.
|
||||
|
||||
Der Abgleich ist eine **Einbahnstraße**: er ergänzt und löscht nie etwas, das
|
||||
in Flimmer steht. Fehlende Titel werden nur mit gesetztem Häkchen
|
||||
aufgenommen — im täglichen Lauf also nie. Danach läuft er im
|
||||
Hintergrundlauf mit.
|
||||
|
||||
*Grenze der Quelle:* Jellyfin kennt `PlayCount` und `LastPlayedDate`, aber
|
||||
keine Liste einzelner Sichtungen. Ein dreimal gesehener Film kommt deshalb
|
||||
mit **einem** Termin an, nicht mit drei. Das steht auch in der Anleitung.
|
||||
|
||||
Das Zurückschreiben nach Jellyfin (Stufe 3) ist noch nicht dabei; das Feld
|
||||
liegt im Datenmodell bereit.
|
||||
|
||||
- **Standardreihenfolge je Universum.** Welche Reihenfolge beim Öffnen
|
||||
erscheint, lässt sich jetzt festlegen — wer Schreibrecht hat, setzt sie für
|
||||
alle. Die Rangfolge bleibt schlicht: Adresse schlägt Standard schlägt erste.
|
||||
Über die Schnittstelle mit `PATCH /api/v1/universen/{id}`.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Rückmeldung beim Tippen** in Suche und Bibliothek. Beim Suchen wird das
|
||||
Trefferraster gedämpft und auf `aria-busy` gesetzt, der Hinweis „sucht …"
|
||||
steht jetzt auch in der Bibliothek. Die alten Treffer bleiben sichtbar —
|
||||
sie verschwinden zu lassen ließe die Liste bei jedem Tastendruck springen.
|
||||
Die Sammelverzögerung liegt einheitlich bei 250 ms.
|
||||
|
||||
- **`update.sh` erkennt jetzt den Container-Zustand.** Schlägt die Abfrage über
|
||||
den veröffentlichten Port fehl, zählt der Healthcheck des Containers —
|
||||
hinter einem Reverse Proxy ist der App-Port oft gar nicht auf dem Host
|
||||
veröffentlicht, und das Skript meldete dann einen Fehlschlag, obwohl das
|
||||
Update sauber durchgelaufen war. Im Fehlerfall nennt es außerdem den Zustand
|
||||
und ordnet ihn ein.
|
||||
|
||||
*Zur Klarstellung:* Der Changelog zu 0.7.1 führte diese Rückfallprüfung
|
||||
bereits auf. Sie war dort beschrieben, aber nicht umgesetzt — jetzt ist sie
|
||||
es.
|
||||
|
||||
- Die Entwicklungsdatenbank hat einen eigenen Compose-Projektnamen
|
||||
(`flimmer-dev`). Vorher leiteten beide Compose-Dateien ihn aus dem
|
||||
Verzeichnis ab, und ein `docker compose down -v` auf dem Produktivstack nahm
|
||||
das Volume der Entwicklungsdatenbank mit.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Ein als gesehen markierter Film ließ sich nicht mehr zurücksetzen.** Im
|
||||
Zustand „gesehen" stand statt eines Knopfes ein Etikett ohne Klickziel, und
|
||||
eine Gegenfunktion gab es nicht — Serien hatten längst einen Umschalter,
|
||||
Filme nur einen Hinweg. Die grüne Plakette ist jetzt klickbar.
|
||||
Zurückgenommen wird dabei **nur der laufende Durchlauf**: wer einen Film
|
||||
dreimal gesehen hat, verliert die früheren Sichtungen nicht.
|
||||
|
||||
- **Die Beschriftungen im Titelraster sprangen**, sobald ein Titel zweizeilig
|
||||
umbrach — die Metazeile saß dann eine Zeilenhöhe tiefer als bei den Nachbarn,
|
||||
und die Reihen waren unterschiedlich hoch. Der Titelblock belegt jetzt immer
|
||||
zwei Zeilen; in der Bibliothek wird zusätzlich der Platz des
|
||||
Fortschrittsbalkens freigehalten, den es nur bei Serien gibt.
|
||||
|
||||
### Getestet
|
||||
|
||||
78 Tests (vorher 52). Neu dazu: Passkey-Verwaltung samt Aussperrsperre, der
|
||||
Jellyfin-Abgleich gegen nachgebildete Antworten, die Standardreihenfolge samt
|
||||
Eindeutigkeit, und das Zurücknehmen einer Filmsichtung über
|
||||
Durchlaufgrenzen hinweg.
|
||||
|
||||
Zusätzlich wurden die Klickwege gegen einen **Produktionsbau** geprüft — die
|
||||
Passkey-Anmeldung mit einem virtuellen Authenticator, der Jellyfin-Abgleich
|
||||
gegen einen nachgebildeten Server samt des Falls, in dem der Gesehen-Status
|
||||
fehlt.
|
||||
|
||||
|
||||
## [0.7.1] — 2026-09-01
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
|
||||
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
|
||||
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
|
||||
wieder antwortet.
|
||||
|
||||
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
|
||||
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
|
||||
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
|
||||
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
|
||||
|
||||
Weitere Eigenheiten:
|
||||
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
|
||||
unterbleibt dann, statt sie zu überfahren.
|
||||
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
|
||||
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
|
||||
Healthcheck des Containers.
|
||||
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
|
||||
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
|
||||
|
||||
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
|
||||
Compose-Datei als Vorgabewert.
|
||||
|
||||
## [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.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
|
||||
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
|
||||
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
|
||||
verfügbar geworden sind.
|
||||
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
|
||||
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
|
||||
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
|
||||
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
|
||||
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
|
||||
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
|
||||
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
|
||||
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
|
||||
zählen mit.
|
||||
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
|
||||
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
|
||||
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
|
||||
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
|
||||
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
|
||||
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
|
||||
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
|
||||
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
|
||||
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
|
||||
Standardton.
|
||||
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
|
||||
„Weiterschauen" und den Kalenderzeitraum.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
|
||||
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
|
||||
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
|
||||
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
|
||||
(„2026-04") aus der Datenbank.
|
||||
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
|
||||
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
|
||||
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
|
||||
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
|
||||
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
|
||||
hängt jetzt als `aria-label` am `<svg>`.
|
||||
|
||||
## [0.4.0] — 2026-09-01
|
||||
|
||||
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
|
||||
Wizarding World oder was auch immer zusammengehört.
|
||||
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
|
||||
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
|
||||
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
|
||||
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
|
||||
Tastatur nicht bedienbar und auf dem Handy fummelig.
|
||||
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
|
||||
vorkommen, lassen sich mit einem Klick übernehmen.
|
||||
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
|
||||
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
|
||||
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
|
||||
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
|
||||
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
|
||||
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
|
||||
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
|
||||
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
|
||||
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
|
||||
Serien hinweg, Freigaben und die Rechteprüfung.
|
||||
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
|
||||
Serien und einer abweichenden Story-Chronologie.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
|
||||
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
|
||||
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
|
||||
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
|
||||
serverseitig.
|
||||
|
||||
## [0.3.0] — 2026-09-01
|
||||
|
||||
Das Kernstück: Bibliothek, Status und Episodenfortschritt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Bibliothek** als Posterraster mit Volltextsuche und Filtern nach Status,
|
||||
Typ, Genre, Streamingdienst und Tag; Sortierung nach zuletzt aktualisiert,
|
||||
hinzugefügt, Titel, Bewertung oder Erscheinungsjahr. Der gesamte Filterstand
|
||||
steht in der URL und ist damit teilbar.
|
||||
- **Fünf Status** je Eintrag (Schaue gerade, Pausiert, Abgeschlossen,
|
||||
Abgebrochen, Watchlist). Der Status ergibt sich beim Abhaken von selbst:
|
||||
die erste Episode startet einen Titel, die letzte schließt ihn ab.
|
||||
Pausiert und Abgebrochen bleiben unangetastet — wer bewusst pausiert hat,
|
||||
will das nicht durch eine Sichtung aufgehoben bekommen.
|
||||
- **Episodenfortschritt**: einzeln abhaken, ganze Staffel auf einmal,
|
||||
„bis hierhin gesehen" für den Einstieg mitten in einer Staffel, und ein
|
||||
**„+1 Episode"**-Knopf, der genau die nächste offene Episode nimmt.
|
||||
Häkchen reagieren sofort und werden zurückgedreht, wenn der Server
|
||||
widerspricht; beim Abhaken glimmt die Zeile einmal warm auf.
|
||||
- **„Als Nächstes dran"** mit Episodencode, Titel und Ausstrahlungsdatum;
|
||||
Fortschritt in Prozent auf Basis der echten Episodenzahl, dazu die
|
||||
verbleibende Laufzeit.
|
||||
- **Rewatch-Zählung**: ein neuer Durchlauf setzt den Fortschritt zurück, die
|
||||
Sichtungen des vorherigen bleiben für die Statistik erhalten. Bei Filmen
|
||||
zählt jeder Durchlauf einzeln.
|
||||
- **Rückgängig** für jede Sichtungsaktion — die Rückmeldung am unteren Rand
|
||||
nennt, was passiert ist, und nimmt es auf Klick zurück.
|
||||
- **Pro Eintrag**: Bewertung von 1 bis 10, Notizfeld, eigene Tags und das Feld
|
||||
„wo schaue ich das", das beim Aufnehmen einen Vorschlag aus der
|
||||
Streaming-Verfügbarkeit erhält und jederzeit überschreibbar ist.
|
||||
- **Papierkorb** statt hartem Löschen: entfernte Titel bleiben 30 Tage liegen
|
||||
und behalten dabei ihren Fortschritt. Wird ein Titel erneut aufgenommen,
|
||||
lebt derselbe Eintrag wieder auf.
|
||||
- **Titel selbst anlegen** für alles, was TMDB nicht kennt. Solche Einträge
|
||||
haben keine TMDB-ID und werden vom Abgleich nie angefasst.
|
||||
- **Seed mit Beispielbibliothek**: erfundener Katalog aus 8 Serien und
|
||||
10 Filmen samt lokal erzeugten Postern, dazu 18 Bibliothekseinträge und
|
||||
85 Sichtungen über die letzten Monate verteilt.
|
||||
- **21 Tests** decken Aufnehmen, Abhaken, Fortschritt, Rewatch, Papierkorb und
|
||||
den Zugriffsschutz zwischen Nutzern ab.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Die Detailseite liegt jetzt unter `/titel/<id>` und hängt an der internen
|
||||
Titel-ID statt an der TMDB-ID — nur so bekommen manuell angelegte Titel
|
||||
dieselbe Seite. Treffer aus der Suche laufen über `/tmdb/<art>/<id>`, das
|
||||
den Titel holt und weiterleitet.
|
||||
- Die Zahl der Build-Worker ist auf vier begrenzt: mit einem Worker je Kern
|
||||
stürzte der Build auf einer 18-Kern-Maschine reproduzierbar ab, weil jeder
|
||||
Worker eine eigene Prisma-Engine hochzieht.
|
||||
|
||||
## [0.2.0] — 2026-09-01
|
||||
|
||||
Metadaten: Flimmer spricht mit TMDB.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **TMDB-Anbindung** für Suche (gemeinsam über Serien und Filme oder getrennt),
|
||||
Detailabruf, Staffeln und Episoden, offizielle Collections sowie die
|
||||
Streaming-Verfügbarkeit in der eingestellten Region. Zugang wahlweise über
|
||||
den klassischen API-Schlüssel oder das Bearer-Token.
|
||||
- **Gemeinsamer Katalog**: Titel werden über (Typ, TMDB-ID) dedupliziert — bei
|
||||
mehreren Nutzern liegt jede Serie samt Episodenliste nur einmal in der
|
||||
Datenbank. `ensureTitle` holt einen Titel nur, wenn er fehlt oder veraltet ist,
|
||||
und fällt bei einem TMDB-Ausfall auf den lokalen Stand zurück.
|
||||
- **Bild-Cache**: Poster und Hintergrundbilder werden einmal geholt und danach
|
||||
über `/api/image` vom eigenen Server ausgeliefert. Größe und Pfad werden
|
||||
streng geprüft, damit der Endpunkt kein offener Proxy wird.
|
||||
- **Suchseite** mit Typfilter; die Anfrage steht in der URL und ist damit
|
||||
teilbar. Getippte Eingaben werden kurz gesammelt, bevor gesucht wird.
|
||||
- **Detailseite** je Titel mit Backdrop, Kopfdaten, Genres, Streamingdiensten
|
||||
(mit den Akzentfarben der großen Anbieter) und aufklappbaren Staffeln samt
|
||||
Episodenliste.
|
||||
- **Testlauf ohne Schlüssel**: nachgebildete TMDB-Antworten unter `tests/` und
|
||||
`npm test` prüfen den kompletten Importpfad gegen eine echte Datenbank.
|
||||
|
||||
### Behoben
|
||||
|
||||
- `importSeries` gab den Titel zurück, bevor Staffel- und Episodenzähler
|
||||
geschrieben waren — die Zähler standen dadurch auf 0.
|
||||
- Poster und Dienstlogos zeigten das kaputte Bildsymbol des Browsers, wenn ein
|
||||
TMDB-Pfad ins Leere lief. Die Rückfalldarstellung liegt jetzt dauerhaft
|
||||
darunter statt an einem `onError`-Handler zu hängen, der bei einem früh
|
||||
gescheiterten Bild gar nicht mehr feuert.
|
||||
|
||||
## [0.1.0] — 2026-09-01
|
||||
|
||||
Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
|
||||
@@ -46,5 +462,12 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
|
||||
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
||||
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
||||
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.1.0...HEAD
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.1...HEAD
|
||||
[0.7.1]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.1
|
||||
[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
|
||||
[0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0
|
||||
[0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0
|
||||
|
||||
@@ -80,9 +80,76 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
||||
- [ ] Optional per ENV: OIDC/SSO (Authelia, Keycloak)
|
||||
- [ ] Registrierung durch Admin abschaltbar; erster Nutzer wird Admin
|
||||
|
||||
## Wichtige technische Merkposten
|
||||
|
||||
- **Next 16**: Request-APIs sind async (`cookies()`, `headers()`, `params`,
|
||||
`searchParams`). `middleware.ts` heißt jetzt `proxy.ts`. Turbopack ist Standard.
|
||||
Breaking Changes stehen in `node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md`.
|
||||
- **Prisma 7**: keine `url` im Schema — Treiber-Adapter (`@prisma/adapter-pg`)
|
||||
im Client, URL in `prisma.config.mjs`. Generator ist `prisma-client`
|
||||
(TypeScript-Ausgabe nach `src/generated/prisma`, gitignored).
|
||||
Config bewusst `.mjs`, damit die CLI im Produktionsimage ohne TS-Loader läuft.
|
||||
- **Tailwind v4**: `@theme inline` reicht die `--fl-*`-Variablen durch,
|
||||
`@custom-variant dark` hängt an `[data-theme="dark"]`.
|
||||
- **Zwei Verläufe**: `--fl-gradient` für Flächen, `--fl-gradient-ink` für Text
|
||||
(im Light Mode abgedunkelt, sonst kein AA). Logo-Stops `--fl-brand-1/2/3`.
|
||||
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
|
||||
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
|
||||
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`.
|
||||
Achtung: das Skript meldet sich **nicht** an — bei geschützten Seiten entsteht
|
||||
kommentarlos ein Bild der Anmeldemaske. Für angemeldete Ansichten den
|
||||
Login-Weg aus `scripts/screenshots.mjs` nachbauen.
|
||||
- Next hängt selbst einen Block an diese Datei an — der bleibt drin.
|
||||
|
||||
## Fortschritt
|
||||
|
||||
- Stand: **Etappe 1 läuft** (Start 2026-09-01 18:09)
|
||||
- ✅ **Etappe 1** (v0.1.0) — 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.**
|
||||
|
||||
- ✅ **Nacharbeiten** (v0.7.0/0.7.1) — Docker verifiziert, „hab ich", `update.sh`. 51 min.
|
||||
- ✅ **Issue-Runde** (v0.8.0) — Passkeys, Jellyfin-Anbindung, Standardreihenfolge,
|
||||
Rückmeldung beim Tippen, Titelraster, Filmsichtung zurücknehmen. 77 min.
|
||||
|
||||
Ab v0.8.0 wird **issue-getrieben** gearbeitet: eigener Branch je Issue,
|
||||
Zeiterfassung in Gitea, Freigabe durch Florian. Details im DEVLOG.
|
||||
|
||||
### 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.
|
||||
- **Wenn Klicks unter Playwright wirkungslos bleiben, liegt es meist nicht am
|
||||
eigenen Code.** Beobachtet: gegen `npm run dev` reagierte unter headless
|
||||
Chromium **keine** Seite — kein Knopf, nicht einmal der Theme-Umschalter,
|
||||
ohne Seitenfehler und ohne fehlgeschlagene Anfrage. Begleitet vom
|
||||
scheiternden HMR-Socket (`ERR_INVALID_HTTP_RESPONSE`).
|
||||
**Der Zustand ist aber nicht dauerhaft:** in einem späteren Lauf gegen
|
||||
denselben Dev-Server funktionierten dieselben Klicks. Der HMR-Fehler tritt
|
||||
in beiden Fällen auf, taugt also nicht als Erklärung.
|
||||
Vorgehen: erst mit `Object.keys(el).some(k => k.startsWith("__react"))`
|
||||
prüfen, ob überhaupt hydratisiert wurde. Bleibt es dabei, gegen einen
|
||||
Produktionsbau prüfen (`npm run build`, `PORT=3012 npm start`) — dort trat
|
||||
es nie auf. Formulare täuschen zusätzlich: die Anmeldung funktioniert auch
|
||||
ohne JavaScript und wirkt deshalb intakt.
|
||||
- Vor `npm start` prüfen, ob der Port noch von einem früheren Lauf belegt ist —
|
||||
sonst startet der neue Server gar nicht und man testet den alten Bau.
|
||||
- **`APP_URL` muss zum tatsächlich aufgerufenen Port passen.** In der `.env`
|
||||
steht `http://localhost:3010`; wer gegen 3011 oder 3012 testet, muss
|
||||
`APP_URL` mitgeben. Passkeys scheitern sonst am Ursprungsabgleich (500),
|
||||
und der OIDC-Rücksprung zeigt auf die falsche Adresse.
|
||||
- Testdaten nach Klickproben wieder herstellen: die Proben schreiben in die
|
||||
geseedete Entwicklungsdatenbank.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
|
||||
@@ -21,10 +21,174 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
|
||||
---
|
||||
|
||||
## Etappe 2 — TMDB-Anbindung (v0.2.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 2.1 | 18:47 | 18:51 | 4 min | TMDB-Client: Suche, Serien-, Film- und Staffelabruf, Collections, Anbieterlisten; kurzer Antwortspeicher |
|
||||
| 2.2 | 18:51 | 18:54 | 3 min | Katalogschicht: Import von Titeln, Staffeln, Episoden und Streamingdiensten; Bild-Cache mit eigenem Ausliefer-Endpunkt |
|
||||
| 2.3 | 18:54 | 18:57 | 3 min | Oberfläche: Suchseite mit Typfilter, Detailseite mit Staffeln, Poster- und Anbieterkomponenten |
|
||||
| 2.4 | 18:57 | 19:01 | 4 min | Nachgebildete TMDB-Antworten und Testlauf (7 Tests); dabei zwei Fehler gefunden und behoben: veraltete Zähler nach dem Serienimport, kaputtes Bildsymbol statt Rückfallkachel |
|
||||
|
||||
**Etappe 2 gesamt: 14 min** (2026-09-01, 18:47 – 19:01)
|
||||
|
||||
---
|
||||
|
||||
## Etappe 3 — Kern-Tracking: Bibliothek, Status, Episodenfortschritt (v0.3.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 3.1 | 19:01 | 19:06 | 5 min | Domänenlogik: Aufnehmen, Status, Sichtungen, Rewatch-Durchläufe, Fortschritt, nächste Episode |
|
||||
| 3.2 | 19:06 | 19:10 | 4 min | 14 Tests dazu; dabei ein Fehler im parallelen Testlauf gefunden (Testdateien räumten sich gegenseitig die Fixtures weg) |
|
||||
| 3.3 | 19:10 | 19:14 | 4 min | Server-Aktionen, Rückmeldungen mit Rückgängig, Bedienelemente (Status, Bewertung, Notizen, Tags, Episodenliste, „+1 Episode") |
|
||||
| 3.4 | 19:14 | 19:17 | 3 min | Bibliotheksseite mit Filtern und Facetten, Papierkorb, Umbau der Detailseite auf die interne Titel-ID |
|
||||
| 3.5 | 19:17 | 19:23 | 6 min | Beispieldaten: erfundener Katalog mit lokal erzeugten Postern, gelebte Bibliothek mit Sichtungshistorie; Klickweg im Browser geprüft (+1 Episode, Rückgängig) |
|
||||
| 3.6 | 19:23 | 19:25 | 2 min | Manuelles Anlegen ohne TMDB-Treffer; Build-Absturz durch zu viele Worker eingegrenzt und behoben |
|
||||
|
||||
**Etappe 3 gesamt: 24 min** (2026-09-01, 19:01 – 19:25)
|
||||
|
||||
---
|
||||
|
||||
## Etappe 4 — Universen mit Reihenfolgen und Fortschritt (v0.4.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 4.1 | 19:25 | 19:30 | 5 min | Domänenlogik: Universen, berechnete Erscheinungsreihenfolge, frei sortierbare Reihenfolgen, Fortschritt aus Sicht des Betrachters, Freigaben |
|
||||
| 4.2 | 19:30 | 19:33 | 3 min | 12 Tests dazu; eine falsche Testannahme zur Reihenfolge korrigiert (nicht den Code) |
|
||||
| 4.3 | 19:33 | 19:36 | 3 min | Server-Aktionen, sortierbare Liste mit Ziehen und Pfeilknöpfen, Reihenfolge-Umschalter, Aufnehmen, Teilen |
|
||||
| 4.4 | 19:36 | 19:39 | 3 min | Seiten für Übersicht, Anlegen und Detailansicht; Build-Fehler durch `server-only` im Browser-Bundle gefunden und behoben; Umsortieren im Browser geprüft |
|
||||
|
||||
**Etappe 4 gesamt: 14 min** (2026-09-01, 19:25 – 19:39)
|
||||
|
||||
---
|
||||
|
||||
## Etappe 5 — Dashboard, Kalender, Statistiken (v0.5.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 5.1 | 19:39 | 19:43 | 4 min | Auswertungsschicht: Weiterschauen, kommende Episoden, neu verfügbar, Gesamtzahlen, Monatsverlauf, Verteilungen, Jahresrückblick |
|
||||
| 5.2 | 19:43 | 19:46 | 3 min | Diagrammfarben gegen die Prüfungen auf Helligkeit, Sättigung, Farbfehlsichtigkeit und Kontrast gerechnet; SVG-Diagramme und Verteilungsbalken gebaut |
|
||||
| 5.3 | 19:46 | 19:50 | 4 min | Seiten für Übersicht, Kalender und Statistik |
|
||||
| 5.4 | 19:50 | 19:54 | 4 min | Drei Fehler gefunden und behoben: leeres Monatsdiagramm durch Zeitzonenversatz, Hydrationsbruch durch SVG-Titel, Kalender ohne abgeschlossene Serien; 7 Tests nachgezogen |
|
||||
|
||||
**Etappe 5 gesamt: 15 min** (2026-09-01, 19:39 – 19:54)
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
---
|
||||
|
||||
## Issue-Runde (v0.8.0)
|
||||
|
||||
Ab hier lief die Arbeit issue-getrieben: jede Aufgabe mit eigenem Branch,
|
||||
Zeiterfassung in Gitea und Freigabe durch Florian. Die Zeiten unten sind die
|
||||
gemessenen Stopwatch-Werte, nicht die Wanduhr — dazwischen lagen Pausen.
|
||||
|
||||
| # | Issue | Dauer | Was passiert ist |
|
||||
|---|---|---|---|
|
||||
| 8.1 | #2 Titelraster | 6 min | Titelblock auf zwei Zeilen festgenagelt; vorher und nachher vermessen |
|
||||
| 8.2 | #4 Standardreihenfolge | 15 min | Feld war da und wurde ausgewertet — es fehlte nur der Weg, es zu setzen |
|
||||
| 8.3 | #5 Gesehen zurücknehmen | 10 min | Der Knopf war ein `<p>`; die Gegenfunktion für Filme fehlte ganz |
|
||||
| 8.4 | #3 Träge Suche | 7 min | Das Ladesignal wurde erzeugt und weggeworfen; zwei Verdächtige durch Messung entlastet |
|
||||
| 8.5 | #6 Passkeys | 20 min | WebAuthn als dritter Weg in dieselbe Sitzung, geprüft mit virtuellem Authenticator |
|
||||
| 8.6 | #1 Jellyfin | 19 min | Bestand und Sichtungen über die TMDB-Kennung, Stufe 1 und 2 |
|
||||
|
||||
**Issues gesamt: 1 h 17 min** (2026-09-01 21:22 – 2026-09-02 07:25)
|
||||
|
||||
Dazu ohne Zeiterfassung: die Nacharbeit an `update.sh` (Rückfall auf den
|
||||
Container-Healthcheck), die Merkposten in `CLAUDE.md` und diese Version
|
||||
selbst — Changelog, Screenshots, Release.
|
||||
|
||||
### Was dabei auffiel
|
||||
|
||||
1. **Zwei eigene Vermutungen hielten der Messung nicht stand.** Bei #3 hatte
|
||||
ich Facetten-Abfragen und blockierende Poster als Ursachen benannt. Gemessen
|
||||
an 2000 Einträgen kosten die Facetten 12 ms, und bei künstlich um 1500 ms
|
||||
verzögerten Bildern stehen die Treffer 891 ms vor den Bildern im DOM. Beide
|
||||
blieben deshalb unangetastet.
|
||||
2. **Ein Nebenpunkt in einem Issue war schlicht falsch.** Ich hatte bei #5
|
||||
behauptet, die Einblendung biete ein wirkungsloses Zurücknehmen an — sie
|
||||
prüft aber längst auf `ids.length > 0`.
|
||||
3. **Der Changelog zu 0.7.1 beschrieb eine Funktion, die es nicht gab.** Die
|
||||
Rückfallprüfung über den Container-Healthcheck stand dort, war aber nie
|
||||
umgesetzt. Jetzt ist sie es.
|
||||
4. **Ein Abgleich mit null Treffern war kein Fehler.** Der erste Jellyfin-Lauf
|
||||
gegen die Beispieldaten übernahm keine einzige Sichtung — korrektes
|
||||
Entdoppeln, die Episoden waren im Seed längst abgehakt.
|
||||
5. **Sitzungen über nacktes HTTP gehen nicht.** Das Cookie ist in der
|
||||
Produktion `Secure`; auf einer LAN-IP kommt man deshalb gar nicht herein,
|
||||
ohne Fehlermeldung. Nicht behoben, festgehalten.
|
||||
|
||||
---
|
||||
|
||||
## Gesamtübersicht
|
||||
|
||||
| Etappe | Version | Dauer |
|
||||
|---|---|---|
|
||||
| 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min |
|
||||
| 2 — TMDB-Anbindung | 0.2.0 | 14 min |
|
||||
| 3 — Kern-Tracking | 0.3.0 | 24 min |
|
||||
| 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 |
|
||||
| Issue-Runde — Passkeys, Jellyfin, vier Nacharbeiten | 0.8.0 | 77 min |
|
||||
|
||||
**Bisher gesamt: 38 min**
|
||||
**Gesamtentwicklungszeit: 4 h 24 min** (264 min, 2026-09-01 18:09 – 2026-09-02 07:25)
|
||||
|
||||
### Womit die Zeit verging
|
||||
|
||||
| Anteil | Bereich |
|
||||
|---|---|
|
||||
| ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) |
|
||||
| ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) |
|
||||
| ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) |
|
||||
| ~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
|
||||
|
||||
@@ -98,14 +114,31 @@ flimmer.example.com {
|
||||
|
||||
```bash
|
||||
cd flimmer
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
./update.sh
|
||||
```
|
||||
|
||||
Datenbankmigrationen laufen automatisch beim Start des Containers — ein eigener
|
||||
Handgriff ist nicht nötig. **Vor** einem Update ein Backup zu ziehen ist
|
||||
trotzdem eine gute Angewohnheit.
|
||||
Das Skript sichert zuerst die Datenbank, holt die Änderungen, baut das Image neu
|
||||
und wartet, bis die Instanz wieder antwortet. Schlägt etwas fehl, nennt es den
|
||||
konkreten Weg zurück — mit dem Befehl und der Sicherungsdatei, nicht bloß dem
|
||||
Rat, ein Backup zu haben.
|
||||
|
||||
```
|
||||
./update.sh --ohne-backup ohne Datenbanksicherung
|
||||
./update.sh --ohne-pull nur neu bauen und starten
|
||||
./update.sh --behalte 20 wie viele Sicherungen aufgehoben werden (Standard: 10)
|
||||
./update.sh --hilfe
|
||||
```
|
||||
|
||||
Die Sicherungen landen unter `backups/` und sind gzip-komprimierte `pg_dump`s.
|
||||
|
||||
Von Hand geht es genauso:
|
||||
|
||||
```bash
|
||||
git pull && docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
Datenbankmigrationen laufen in beiden Fällen automatisch beim Start des
|
||||
Containers — ein eigener Handgriff ist nicht nötig.
|
||||
|
||||
## Backup
|
||||
|
||||
@@ -145,6 +178,31 @@ mit Erklärungen steht in [`.env.example`](.env.example). Die wichtigsten:
|
||||
| `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen |
|
||||
| `SMTP_*` | E-Mail-Versand |
|
||||
| `OIDC_*` | Optionale SSO-Anbindung (Authelia, Keycloak, Authentik …) |
|
||||
| `ENABLE_PASSKEYS` | Anmeldung per Passkey (Standard `true`) |
|
||||
|
||||
### Passkeys
|
||||
|
||||
Statt Passwort geht auch ein Passkey — Windows Hello, Face ID, Touch ID oder ein
|
||||
Sicherheitsschlüssel. Anlegen unter **Einstellungen → Passkeys**, anmelden über
|
||||
„Mit Passkey anmelden" ohne vorherige E-Mail-Eingabe. Das Passwort bleibt
|
||||
daneben bestehen; den letzten verbleibenden Anmeldeweg lässt Flimmer nicht
|
||||
entfernen.
|
||||
|
||||
Drei Dinge, die beim Selbsthosten zählen:
|
||||
|
||||
- **HTTPS ist Pflicht.** Der Browser gibt Passkeys nur über eine verschlüsselte
|
||||
Verbindung frei; einzige Ausnahme ist `localhost`. Läuft deine Instanz über
|
||||
einfaches HTTP, verschwindet die Passkey-Verwaltung mit einem Hinweis, statt
|
||||
einen kryptischen Browserfehler zu zeigen.
|
||||
- **Der Hostname kommt aus `APP_URL`**, nicht aus dem `Host`-Header. Hinter
|
||||
einem Reverse Proxy muss `APP_URL` also stimmen, sonst hängen die Passkeys am
|
||||
falschen Namen.
|
||||
- **Ein Domainwechsel entwertet alle Passkeys.** Wer von `flimmer.fritz.box` auf
|
||||
`flimmer.example.com` umzieht, muss sie neu anlegen — sie sind fest an den
|
||||
Hostnamen gebunden. Halte vorher einen zweiten Anmeldeweg bereit.
|
||||
|
||||
Ein Passkey, der nur auf einem Gerät liegt (nicht synchronisiert), ist mit dem
|
||||
Gerät verloren. Die Übersicht in den Einstellungen sagt dazu, was Sache ist.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -165,22 +223,56 @@ npm run dev
|
||||
| `npm run db:migrate` | Migration erzeugen und anwenden |
|
||||
| `npm run db:studio` | Prisma Studio |
|
||||
| `npm run db:seed` | Beispieldaten |
|
||||
| `npm test` | Testlauf gegen die Entwicklungsdatenbank (braucht keinen TMDB-Schlüssel) |
|
||||
| `npm run icons` | Favicon und PWA-Icons aus dem SVG-Logo neu erzeugen |
|
||||
| `npm run screenshots` | Screenshots der Anleitung neu aufnehmen |
|
||||
|
||||
Ohne TMDB-Schlüssel lässt sich die Suche mit einem lokalen Ersatz ausprobieren,
|
||||
der aus den Beispieldaten antwortet:
|
||||
|
||||
```bash
|
||||
node --import tsx scripts/tmdb-stub.mjs 3099
|
||||
TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
|
||||
```
|
||||
|
||||
Aufbau des Projekts:
|
||||
|
||||
```
|
||||
src/
|
||||
app/ Routen (App Router), API-Endpunkte
|
||||
app/ Routen (App Router), API-Endpunkte, Server-Aktionen
|
||||
components/ Wiederverwendbare Bausteine
|
||||
lib/ Serverseitige Logik: DB, Auth, TMDB, Benachrichtigungen
|
||||
lib/ Serverseitige Logik: DB, Auth, TMDB, Tracking, Statistik,
|
||||
Universen, Benachrichtigungen, Abgleich, Import/Export
|
||||
i18n/ Oberflächentexte
|
||||
prisma/ Schema, Migrationen, Seed
|
||||
prisma/ Schema, Migrationen, Seed samt Beispielkatalog
|
||||
tests/ Testlauf mit nachgebildeten TMDB-Antworten
|
||||
public/brand/ Logo in allen Varianten
|
||||
docker/ Entrypoint des Containers
|
||||
docs/ Anleitung, Design-Dokumentation
|
||||
docs/ Anleitung mit Screenshots, API, Design-Dokumentation
|
||||
```
|
||||
|
||||
### Automatisierung
|
||||
|
||||
Unter `.gitea/workflows/` liegen zwei Abläufe für Gitea Actions: `ci.yml`
|
||||
prüft bei jedem Push Typen, Tests und Produktionsbuild gegen eine echte
|
||||
PostgreSQL-Instanz, `release.yml` legt beim Pushen eines Versions-Tags das
|
||||
Release an — mit den Notizen aus `releases/vX.Y.Z.md`.
|
||||
|
||||
## Hintergrundlauf
|
||||
|
||||
Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue
|
||||
Episoden, prüft die Verfügbarkeit von Watchlist-Titeln und leert den
|
||||
Papierkorb. Gesteuert wird das über `ENABLE_SCHEDULER` und `SYNC_HOUR`.
|
||||
|
||||
Wer lieber einen eigenen Cron benutzt, setzt `ENABLE_SCHEDULER=false`, vergibt
|
||||
ein `CRON_SECRET` und ruft auf:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://flimmer.example.com/api/cron/sync
|
||||
```
|
||||
|
||||
In der Verwaltung lässt sich der Lauf jederzeit von Hand anstoßen.
|
||||
|
||||
## Technik
|
||||
|
||||
Next.js (App Router) mit TypeScript, PostgreSQL über Prisma, Tailwind CSS.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`.
|
||||
# docker compose -f docker-compose.dev.yml up -d
|
||||
#
|
||||
# Eigener Projektname: sonst teilt sich dieser Stack den Namen mit der
|
||||
# Produktiv-Compose (beide leiten ihn sonst aus dem Verzeichnis ab), und ein
|
||||
# `docker compose down -v` dort nimmt die Entwicklungsdatenbank mit.
|
||||
name: flimmer-dev
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
|
||||
@@ -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,489 @@
|
||||
<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.
|
||||
|
||||
---
|
||||
|
||||
## Anmelden per Passkey
|
||||
|
||||

|
||||
|
||||
Statt Passwort geht auch ein **Passkey** — Windows Hello, Face ID, Touch ID
|
||||
oder ein Sicherheitsschlüssel am Schlüsselbund. Auf dem Handy ist das der
|
||||
Unterschied zwischen einem Blick in die Kamera und dem Abtippen eines langen
|
||||
Passworts.
|
||||
|
||||
Unter **Einstellungen → Passkeys** gibst du dem Gerät einen Namen und klickst
|
||||
**Passkey anlegen**; den Rest übernimmt dein Browser. Danach steht auf der
|
||||
Anmeldeseite „Mit Passkey anmelden" — **ohne** dass du vorher deine
|
||||
E-Mail-Adresse eintippen musst.
|
||||
|
||||
Das Passwort bleibt daneben bestehen. Den **letzten verbleibenden Anmeldeweg**
|
||||
lässt Flimmer nicht entfernen: wer weder Passwort noch SSO hat, behält seinen
|
||||
letzten Passkey.
|
||||
|
||||
> **Passkeys brauchen HTTPS**
|
||||
> Der Browser gibt sie nur über eine verschlüsselte Verbindung frei; einzige
|
||||
> Ausnahme ist `localhost`. Läuft deine Instanz über einfaches HTTP,
|
||||
> verschwindet die Verwaltung mit einem Hinweis, statt einen kryptischen
|
||||
> Browserfehler zu zeigen.
|
||||
>
|
||||
> Und: Ein Passkey ist fest an den **Hostnamen** gebunden. Ziehst du von
|
||||
> `flimmer.fritz.box` auf `flimmer.example.com` um, musst du alle Passkeys neu
|
||||
> anlegen. Halte vorher einen zweiten Anmeldeweg bereit.
|
||||
|
||||
Ein Passkey, der nur auf einem Gerät liegt und nicht mit deinem Konto
|
||||
synchronisiert wird, ist mit dem Gerät verloren. Die Übersicht sagt dir zu
|
||||
jedem Eintrag, was Sache ist.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## Jellyfin anbinden
|
||||
|
||||

|
||||
|
||||
Wer seine Mediathek selbst hostet, pflegt Bestand und Gesehen-Status meist
|
||||
schon in Jellyfin. Flimmer kann beides übernehmen, damit du es nicht doppelt
|
||||
führst.
|
||||
|
||||
Unter **Einstellungen → Medienserver** trägst du die Adresse deines Servers
|
||||
ein, dazu Benutzername und Passwort. Nach dem Verbinden sagt Flimmer, wie
|
||||
viele Einträge es gefunden hat — und ob der **Gesehen-Status mitkommt**.
|
||||
|
||||
> **Warum Benutzername und Passwort, nicht ein API-Schlüssel?**
|
||||
> Mit einem API-Schlüssel liefert Jellyfin zwar den Bestand, aber keinen
|
||||
> Gesehen-Status — und zwar ohne Fehlermeldung. Der Abgleich liefe dann stumm
|
||||
> ins Leere. Flimmer meldet sich deshalb wie ein normaler Nutzer an und prüft
|
||||
> beim Verbinden ausdrücklich nach. Das Zugangstoken wird verschlüsselt
|
||||
> abgelegt, dein Passwort gar nicht gespeichert.
|
||||
|
||||
Zugeordnet wird über die **TMDB-Kennung**, die Jellyfin zu jedem Film und jeder
|
||||
Serie mitliefert. Das ist dieselbe Kennung, auf der Flimmers Katalog aufbaut —
|
||||
es gibt also kein Raten über Titel und Jahreszahlen. Was Jellyfin ohne Kennung
|
||||
führt (eigene Aufnahmen etwa), landet im Bericht statt falsch zugeordnet zu
|
||||
werden.
|
||||
|
||||
Beim Abgleich passiert dreierlei:
|
||||
|
||||
- Titel, die auf dem Server liegen, bekommen **„hab ich"** mit der Quelle
|
||||
Jellyfin.
|
||||
- Was du dort gesehen hast, wird zur **Sichtung** mit dem Termin aus Jellyfin.
|
||||
- Fehlende Titel werden **nur aufgenommen, wenn du das Häkchen setzt**. Eine
|
||||
Mediathek mit achthundert Filmen soll nicht ungefragt deine Bibliothek
|
||||
werden.
|
||||
|
||||
Danach läuft der Abgleich im täglichen Hintergrundlauf mit — dort allerdings
|
||||
ohne automatisches Aufnehmen.
|
||||
|
||||
**Der Abgleich ist eine Einbahnstraße.** Er ergänzt, was auf dem Server steht,
|
||||
und löscht nie etwas, das in Flimmer steht. Markierst du einen Film in Flimmer
|
||||
als gesehen und löschst ihn in Jellyfin, bleibt deine Sichtung erhalten.
|
||||
|
||||
> **Eine Grenze, die von Jellyfin kommt**
|
||||
> Jellyfin merkt sich, *wie oft* du etwas gesehen hast und *wann zuletzt* —
|
||||
> aber keine Liste der einzelnen Male. Ein Film, den du dreimal gesehen hast,
|
||||
> kommt deshalb mit **einem** Termin in Flimmers Statistik an, nicht mit drei.
|
||||
> Rewatch-Durchläufe, die du in Flimmer selbst anlegst, sind davon nicht
|
||||
> betroffen. Das lässt sich nicht beheben — die Information gibt es an der
|
||||
> Quelle nicht.
|
||||
|
||||
---
|
||||
|
||||
## 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,227 @@
|
||||
# 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.
|
||||
|
||||
Für Filme gibt es `{"action":"movie"}` als Gegenstück zum gleichnamigen POST;
|
||||
dafür braucht man die `watchIds` nicht zu kennen:
|
||||
|
||||
```bash
|
||||
curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||
-d '{"action":"movie"}' \
|
||||
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen
|
||||
```
|
||||
|
||||
Zurückgenommen wird dabei **nur der laufende Durchlauf**. Wer einen Film dreimal
|
||||
gesehen hat, verliert also nicht die Sichtungen der früheren Durchläufe — die
|
||||
zählen weiter in die Statistik.
|
||||
|
||||
### 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}
|
||||
PATCH /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
|
||||
Standardreihenfolge des Universums — in `orders` trägt sie `isDefault`.
|
||||
|
||||
`PATCH` legt fest, welche das ist. Die Reihenfolge muss zu diesem Universum
|
||||
gehören, und es braucht Schreibrecht:
|
||||
|
||||
```bash
|
||||
curl -X PATCH -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||
-d '{"defaultOrderId": "…"}' \
|
||||
https://flimmer.example.com/api/v1/universen/$ID
|
||||
```
|
||||
|
||||
Je Universum trägt immer genau eine Reihenfolge den Standard. Wird die
|
||||
Standardreihenfolge gelöscht, übernimmt 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: 13 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 64 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: 39 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -4,14 +4,18 @@ const nextConfig: NextConfig = {
|
||||
// Schlankes Docker-Image: Next kopiert nur die wirklich benötigten Dateien.
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
// Begrenzte Zahl an Build-Workern. Mit der Voreinstellung (ein Worker je
|
||||
// Kern) stürzte der Build auf einer 18-Kern-Maschine beim Einsammeln der
|
||||
// Seitendaten reproduzierbar ab (Windows-Fehler 0xC0000409); jeder Worker
|
||||
// zieht eine eigene Prisma-Engine hoch. Vier Worker sind schnell genug und
|
||||
// laufen zuverlässig.
|
||||
cpus: 4,
|
||||
},
|
||||
poweredByHeader: false,
|
||||
serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"],
|
||||
// Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über
|
||||
// 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,16 +1,18 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flimmer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.7.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.3",
|
||||
"bcryptjs": "3.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"date-fns": "4.4.0",
|
||||
@@ -25,7 +27,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",
|
||||
@@ -537,6 +538,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
@@ -1136,6 +1143,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz",
|
||||
@@ -1282,6 +1295,207 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz",
|
||||
"integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz",
|
||||
"integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-attr": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz",
|
||||
"integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz",
|
||||
"integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz",
|
||||
"integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-rsa": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz",
|
||||
"integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz",
|
||||
"integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.9.4",
|
||||
"@peculiar/asn1-pfx": "^2.9.4",
|
||||
"@peculiar/asn1-pkcs8": "^2.9.4",
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"@peculiar/asn1-x509-attr": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz",
|
||||
"integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz",
|
||||
"integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz",
|
||||
"integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz",
|
||||
"integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.9.4",
|
||||
"@peculiar/asn1-x509": "^2.9.4",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
|
||||
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/adapter-pg": {
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz",
|
||||
@@ -1646,6 +1860,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "13.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.3.tgz",
|
||||
"integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.1",
|
||||
"@peculiar/asn1-rsa": "^2.6.1",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"@peculiar/x509": "^1.14.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -1945,17 +2184,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",
|
||||
@@ -2597,6 +2825,20 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
@@ -4082,6 +4324,24 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
|
||||
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
|
||||
@@ -4128,6 +4388,12 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/remeda": {
|
||||
"version": "2.33.4",
|
||||
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
||||
@@ -4441,6 +4707,24 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||
"license": "MIT",
|
||||
@@ -15,11 +15,15 @@
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"icons": "tsx scripts/generate-icons.ts",
|
||||
"screenshots": "tsx scripts/screenshots.ts"
|
||||
"screenshots": "node scripts/screenshots.mjs && node scripts/optimize-screenshots.mjs",
|
||||
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts",
|
||||
"tmdb:stub": "node --import tsx scripts/tmdb-stub.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.3",
|
||||
"bcryptjs": "3.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"date-fns": "4.4.0",
|
||||
@@ -34,7 +38,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",
|
||||
@@ -49,5 +52,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@7.10.0": true,
|
||||
"@prisma/engines@7.10.0": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Beispielkatalog für die Entwicklung.
|
||||
*
|
||||
* Bewusst **erfundene** Titel statt echter TMDB-Daten: das Seed-Skript soll
|
||||
* ohne TMDB-Schlüssel laufen, keine fremden Metadaten ins Repository ziehen
|
||||
* und trotzdem eine Bibliothek zeigen, die sich wie eine echte anfühlt —
|
||||
* laufende Serien, abgeschlossene, ein Universum, unterschiedliche Dienste.
|
||||
*
|
||||
* Die Poster erzeugt `prisma/demo/posters.ts` lokal aus diesen Angaben.
|
||||
*/
|
||||
|
||||
export type DemoSeason = {
|
||||
seasonNumber: number;
|
||||
name: string;
|
||||
airDate: string;
|
||||
/** Episodentitel; die Nummern ergeben sich aus der Reihenfolge. */
|
||||
episodes: string[];
|
||||
runtime: number;
|
||||
};
|
||||
|
||||
export type DemoSeries = {
|
||||
key: string;
|
||||
type: "SERIES";
|
||||
title: string;
|
||||
originalTitle?: string;
|
||||
tagline?: string;
|
||||
overview: string;
|
||||
firstAirDate: string;
|
||||
lastAirDate?: string;
|
||||
status: string;
|
||||
inProduction: boolean;
|
||||
genres: string[];
|
||||
runtime: number;
|
||||
voteAverage: number;
|
||||
providers: { name: string; kind: "FLATRATE" | "FREE" | "RENT" | "BUY" | "ADS" }[];
|
||||
seasons: DemoSeason[];
|
||||
};
|
||||
|
||||
export type DemoMovie = {
|
||||
key: string;
|
||||
type: "MOVIE";
|
||||
title: string;
|
||||
originalTitle?: string;
|
||||
tagline?: string;
|
||||
overview: string;
|
||||
releaseDate: string;
|
||||
runtime: number;
|
||||
genres: string[];
|
||||
voteAverage: number;
|
||||
collection?: string;
|
||||
providers: { name: string; kind: "FLATRATE" | "FREE" | "RENT" | "BUY" | "ADS" }[];
|
||||
};
|
||||
|
||||
export type DemoTitle = DemoSeries | DemoMovie;
|
||||
|
||||
/** Erzeugt Episodentitel im Muster „Kapitel 1 … n" für längere Staffeln. */
|
||||
function chapters(prefix: string, count: number): string[] {
|
||||
return Array.from({ length: count }, (_, index) => `${prefix} ${index + 1}`);
|
||||
}
|
||||
|
||||
export const DEMO_SERIES: DemoSeries[] = [
|
||||
{
|
||||
key: "nordlicht",
|
||||
type: "SERIES",
|
||||
title: "Nordlicht",
|
||||
originalTitle: "Nordlys",
|
||||
tagline: "Manche Lichter wollen nicht gesehen werden.",
|
||||
overview:
|
||||
"Eine Meteorologin kehrt in ihr Heimatdorf am Polarkreis zurück und stößt auf ein Wetterphänomen, das es nicht geben dürfte. Je näher sie der Erklärung kommt, desto weniger will das Dorf sie hören.",
|
||||
firstAirDate: "2023-03-14",
|
||||
lastAirDate: "2025-05-02",
|
||||
status: "Returning Series",
|
||||
inProduction: true,
|
||||
genres: ["Drama", "Mystery"],
|
||||
runtime: 48,
|
||||
voteAverage: 8.1,
|
||||
providers: [
|
||||
{ name: "Netflix", kind: "FLATRATE" },
|
||||
{ name: "WOW", kind: "FLATRATE" },
|
||||
],
|
||||
seasons: [
|
||||
{
|
||||
seasonNumber: 1,
|
||||
name: "Staffel 1",
|
||||
airDate: "2023-03-14",
|
||||
runtime: 48,
|
||||
episodes: [
|
||||
"Ankunft",
|
||||
"Der Messwert",
|
||||
"Polarnacht",
|
||||
"Was der Schnee behält",
|
||||
"Drei Grad zu warm",
|
||||
"Das Gerät",
|
||||
"Nordlys",
|
||||
"Rückkehr des Lichts",
|
||||
],
|
||||
},
|
||||
{
|
||||
seasonNumber: 2,
|
||||
name: "Staffel 2",
|
||||
airDate: "2025-04-11",
|
||||
runtime: 50,
|
||||
episodes: [
|
||||
"Der lange Winter",
|
||||
"Zweiter Frühling",
|
||||
"Messreihe",
|
||||
"Der Bruder",
|
||||
"Vermisst",
|
||||
"Weißes Rauschen",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "kaffeesatz",
|
||||
type: "SERIES",
|
||||
title: "Kaffeesatz",
|
||||
tagline: "Jeder Tisch hat eine Geschichte. Manche stimmen sogar.",
|
||||
overview:
|
||||
"In einem Kölner Café laufen die Leben von sechs Stammgästen zusammen. Halbstünder über Nachbarschaft, Missverständnisse und den Versuch, erwachsen zu wirken.",
|
||||
firstAirDate: "2021-09-02",
|
||||
lastAirDate: "2024-11-21",
|
||||
status: "Ended",
|
||||
inProduction: false,
|
||||
genres: ["Comedy", "Drama"],
|
||||
runtime: 26,
|
||||
voteAverage: 7.6,
|
||||
providers: [
|
||||
{ name: "ARD Mediathek", kind: "FREE" },
|
||||
{ name: "Joyn", kind: "ADS" },
|
||||
],
|
||||
seasons: [
|
||||
{ seasonNumber: 1, name: "Staffel 1", airDate: "2021-09-02", runtime: 26, episodes: chapters("Runde", 10) },
|
||||
{ seasonNumber: 2, name: "Staffel 2", airDate: "2022-10-06", runtime: 26, episodes: chapters("Nachschlag", 10) },
|
||||
{ seasonNumber: 3, name: "Staffel 3", airDate: "2024-09-19", runtime: 27, episodes: chapters("Letzte Bestellung", 8) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-serie",
|
||||
type: "SERIES",
|
||||
title: "Sternenpfad: Die Grenzwacht",
|
||||
tagline: "Am Rand der Karte beginnt die Verantwortung.",
|
||||
overview:
|
||||
"Zwischen dem dritten und dem vierten Film der Sternenpfad-Reihe: eine Grenzstation, zwei verfeindete Flotten und eine Kommandantin, die beide Seiten kennt.",
|
||||
firstAirDate: "2022-11-16",
|
||||
lastAirDate: "2024-01-24",
|
||||
status: "Ended",
|
||||
inProduction: false,
|
||||
genres: ["Science Fiction", "Abenteuer", "Drama"],
|
||||
runtime: 42,
|
||||
voteAverage: 8.4,
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
seasons: [
|
||||
{ seasonNumber: 1, name: "Staffel 1", airDate: "2022-11-16", runtime: 42, episodes: chapters("Wache", 8) },
|
||||
{ seasonNumber: 2, name: "Staffel 2", airDate: "2024-01-24", runtime: 44, episodes: chapters("Ablösung", 8) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-akademie",
|
||||
type: "SERIES",
|
||||
title: "Sternenpfad: Akademie",
|
||||
tagline: "Vor der Flotte kommt die Prüfung.",
|
||||
overview:
|
||||
"Jugendserie im Sternenpfad-Universum: der erste Jahrgang einer neu gegründeten Flottenakademie, kurz nach den Ereignissen des zweiten Films.",
|
||||
firstAirDate: "2026-02-06",
|
||||
status: "Returning Series",
|
||||
inProduction: true,
|
||||
genres: ["Science Fiction", "Abenteuer"],
|
||||
runtime: 38,
|
||||
voteAverage: 7.2,
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
seasons: [
|
||||
{ seasonNumber: 1, name: "Staffel 1", airDate: "2026-02-06", runtime: 38, episodes: chapters("Semester", 10) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "aktenlage",
|
||||
type: "SERIES",
|
||||
title: "Aktenlage",
|
||||
tagline: "Die Wahrheit liegt im Anhang.",
|
||||
overview:
|
||||
"Eine Verwaltungsjuristin deckt in einem Landesministerium einen Vorgang auf, den es offiziell nie gab. Bürokratie-Thriller ohne einen einzigen Schusswechsel.",
|
||||
firstAirDate: "2024-10-03",
|
||||
status: "Returning Series",
|
||||
inProduction: true,
|
||||
genres: ["Thriller", "Drama"],
|
||||
runtime: 52,
|
||||
voteAverage: 8.0,
|
||||
providers: [
|
||||
{ name: "ZDF Mediathek", kind: "FREE" },
|
||||
{ name: "Amazon Prime Video", kind: "FLATRATE" },
|
||||
],
|
||||
seasons: [
|
||||
{ seasonNumber: 1, name: "Staffel 1", airDate: "2024-10-03", runtime: 52, episodes: chapters("Vorgang", 6) },
|
||||
{ seasonNumber: 2, name: "Staffel 2", airDate: "2026-09-24", runtime: 52, episodes: chapters("Widerspruch", 6) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "tiefenschaerfe",
|
||||
type: "SERIES",
|
||||
title: "Tiefenschärfe",
|
||||
tagline: "Sehen ist nicht dasselbe wie erkennen.",
|
||||
overview:
|
||||
"Eine Tatortfotografin merkt, dass auf ihren eigenen Aufnahmen jemand steht, der dort nicht war. Sechs Teile, ein einziger Fall.",
|
||||
firstAirDate: "2025-01-09",
|
||||
lastAirDate: "2025-02-13",
|
||||
status: "Ended",
|
||||
inProduction: false,
|
||||
genres: ["Krimi", "Mystery"],
|
||||
runtime: 45,
|
||||
voteAverage: 7.9,
|
||||
providers: [{ name: "Netflix", kind: "FLATRATE" }],
|
||||
seasons: [
|
||||
{ seasonNumber: 1, name: "Miniserie", airDate: "2025-01-09", runtime: 45, episodes: chapters("Blende", 6) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "gartenjahr",
|
||||
type: "SERIES",
|
||||
title: "Ein Gartenjahr",
|
||||
tagline: "Zwölf Monate, ein Beet, kein Drehbuch.",
|
||||
overview:
|
||||
"Dokureihe: eine Familie legt auf einer Brache in Leipzig einen Gemeinschaftsgarten an. Jede Folge ein Monat.",
|
||||
firstAirDate: "2025-03-01",
|
||||
lastAirDate: "2026-02-01",
|
||||
status: "Ended",
|
||||
inProduction: false,
|
||||
genres: ["Dokumentation"],
|
||||
runtime: 30,
|
||||
voteAverage: 7.4,
|
||||
providers: [{ name: "ARTE", kind: "FREE" }],
|
||||
seasons: [
|
||||
{
|
||||
seasonNumber: 1,
|
||||
name: "Staffel 1",
|
||||
airDate: "2025-03-01",
|
||||
runtime: 30,
|
||||
episodes: [
|
||||
"März: Umgraben",
|
||||
"April: Aussaat",
|
||||
"Mai: Frost",
|
||||
"Juni: Schnecken",
|
||||
"Juli: Zu viel Zucchini",
|
||||
"August: Dürre",
|
||||
"September: Ernte",
|
||||
"Oktober: Einkochen",
|
||||
"November: Laub",
|
||||
"Dezember: Planung",
|
||||
"Januar: Katalog",
|
||||
"Februar: Vorziehen",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "zwischenstopp",
|
||||
type: "SERIES",
|
||||
title: "Zwischenstopp",
|
||||
tagline: "Zwanzig Minuten Aufenthalt.",
|
||||
overview:
|
||||
"Anthologieserie: jede Folge spielt vollständig auf einem anderen Bahnhof, in Echtzeit, zwischen zwei Zügen.",
|
||||
firstAirDate: "2026-05-14",
|
||||
status: "Returning Series",
|
||||
inProduction: true,
|
||||
genres: ["Drama", "Anthologie"],
|
||||
runtime: 22,
|
||||
voteAverage: 8.6,
|
||||
providers: [{ name: "Apple TV+", kind: "FLATRATE" }],
|
||||
seasons: [
|
||||
{
|
||||
seasonNumber: 1,
|
||||
name: "Staffel 1",
|
||||
airDate: "2026-05-14",
|
||||
runtime: 22,
|
||||
episodes: [
|
||||
"Hamm (Westf)",
|
||||
"Bad Bentheim",
|
||||
"Offenburg",
|
||||
"Bebra",
|
||||
"Neustadt (Schwarzwald)",
|
||||
"Probstzella",
|
||||
"Zoo Berlin",
|
||||
"Emmerich",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_MOVIES: DemoMovie[] = [
|
||||
// --- Sternenpfad-Reihe (Grundlage für das Beispiel-Universum) -------------
|
||||
{
|
||||
key: "sternenpfad-1",
|
||||
type: "MOVIE",
|
||||
title: "Sternenpfad",
|
||||
tagline: "Ein Kurs. Kein Zurück.",
|
||||
overview:
|
||||
"Eine Frachterpilotin nimmt einen Auftrag an, der sie über die Grenze der bekannten Karte hinausführt — und findet dort eine Flotte, die es nicht geben sollte.",
|
||||
releaseDate: "2016-12-15",
|
||||
runtime: 137,
|
||||
genres: ["Science Fiction", "Abenteuer"],
|
||||
voteAverage: 7.8,
|
||||
collection: "Sternenpfad-Reihe",
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-2",
|
||||
type: "MOVIE",
|
||||
title: "Sternenpfad II: Die stille Flotte",
|
||||
tagline: "Schweigen ist auch eine Antwort.",
|
||||
overview:
|
||||
"Zwei Jahre später: die Flotte antwortet nicht mehr. Eine Delegation soll herausfinden, warum — und findet eine Antwort, die niemand hören will.",
|
||||
releaseDate: "2019-07-04",
|
||||
runtime: 146,
|
||||
genres: ["Science Fiction", "Abenteuer", "Drama"],
|
||||
voteAverage: 8.2,
|
||||
collection: "Sternenpfad-Reihe",
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-3",
|
||||
type: "MOVIE",
|
||||
title: "Sternenpfad III: Heimkehr",
|
||||
tagline: "Manche Wege führen nur in eine Richtung.",
|
||||
overview:
|
||||
"Der Abschluss der ersten Trilogie. Die Rückkehr gelingt — aber nicht allen, und nicht dorthin, wo sie aufgebrochen sind.",
|
||||
releaseDate: "2021-12-16",
|
||||
runtime: 158,
|
||||
genres: ["Science Fiction", "Abenteuer"],
|
||||
voteAverage: 7.5,
|
||||
collection: "Sternenpfad-Reihe",
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-4",
|
||||
type: "MOVIE",
|
||||
title: "Sternenpfad IV: Die Kartografin",
|
||||
tagline: "Wer die Karte zeichnet, bestimmt den Rand.",
|
||||
overview:
|
||||
"Eine Generation später. Eine junge Kartografin zeichnet die Grenze neu — und macht damit aus einer Legende ein politisches Problem.",
|
||||
releaseDate: "2024-12-19",
|
||||
runtime: 149,
|
||||
genres: ["Science Fiction", "Drama"],
|
||||
voteAverage: 8.0,
|
||||
collection: "Sternenpfad-Reihe",
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-null",
|
||||
type: "MOVIE",
|
||||
title: "Sternenpfad: Null",
|
||||
tagline: "Bevor jemand hinaussah.",
|
||||
overview:
|
||||
"Vorgeschichte der Reihe: der Bau des ersten Fernaufklärers und die Entscheidung, die alles auslöste. Chronologisch der Anfang, im Kino der fünfte Teil.",
|
||||
releaseDate: "2026-07-16",
|
||||
runtime: 132,
|
||||
genres: ["Science Fiction", "Drama"],
|
||||
voteAverage: 7.1,
|
||||
collection: "Sternenpfad-Reihe",
|
||||
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
|
||||
},
|
||||
|
||||
// --- Einzelfilme ----------------------------------------------------------
|
||||
{
|
||||
key: "stille-bilder",
|
||||
type: "MOVIE",
|
||||
title: "Die Stille zwischen den Bildern",
|
||||
originalTitle: "The Silence Between Frames",
|
||||
tagline: "24 Bilder pro Sekunde. Eins davon lügt.",
|
||||
overview:
|
||||
"Ein Filmvorführer entdeckt in einer alten Rolle Bilder, die dort nicht sein können — und niemand außer ihm sieht sie.",
|
||||
releaseDate: "2024-10-17",
|
||||
runtime: 118,
|
||||
genres: ["Horror", "Thriller"],
|
||||
voteAverage: 7.4,
|
||||
collection: "Vorführer-Reihe",
|
||||
providers: [
|
||||
{ name: "WOW", kind: "FLATRATE" },
|
||||
{ name: "Google Play Movies", kind: "RENT" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "stille-bilder-2",
|
||||
type: "MOVIE",
|
||||
title: "Die Stille zwischen den Bildern II",
|
||||
tagline: "Diesmal sieht es zurück.",
|
||||
overview: "Fortsetzung: dieselbe Rolle, ein anderes Kino, dreißig Jahre später.",
|
||||
releaseDate: "2026-02-06",
|
||||
runtime: 126,
|
||||
genres: ["Horror", "Thriller"],
|
||||
voteAverage: 6.9,
|
||||
collection: "Vorführer-Reihe",
|
||||
providers: [{ name: "Google Play Movies", kind: "RENT" }],
|
||||
},
|
||||
{
|
||||
key: "hochwasser",
|
||||
type: "MOVIE",
|
||||
title: "Hochwasser",
|
||||
tagline: "Das Wasser fragt nicht.",
|
||||
overview:
|
||||
"Drei Tage in einer Kleinstadt an der Ahr. Kammerspiel über Nachbarschaft, wenn alles gleichzeitig passiert.",
|
||||
releaseDate: "2023-08-24",
|
||||
runtime: 104,
|
||||
genres: ["Drama"],
|
||||
voteAverage: 8.3,
|
||||
providers: [{ name: "ARD Mediathek", kind: "FREE" }],
|
||||
},
|
||||
{
|
||||
key: "pfand",
|
||||
type: "MOVIE",
|
||||
title: "Pfand",
|
||||
tagline: "Fünfundzwanzig Cent Unterschied.",
|
||||
overview:
|
||||
"Komödie über zwei Rentner, die in Hamburg-Barmbek einen Automatenkrieg anzetteln — ohne es zu merken.",
|
||||
releaseDate: "2025-06-12",
|
||||
runtime: 96,
|
||||
genres: ["Komödie"],
|
||||
voteAverage: 7.0,
|
||||
providers: [{ name: "Joyn", kind: "ADS" }],
|
||||
},
|
||||
{
|
||||
key: "die-vermessung",
|
||||
type: "MOVIE",
|
||||
title: "Die Vermessung des Feierabends",
|
||||
tagline: "Achtzehn Uhr. Und dann?",
|
||||
overview:
|
||||
"Dokumentarfilm über vier Menschen und die Frage, was von einem Tag übrig bleibt, wenn die Arbeit vorbei ist.",
|
||||
releaseDate: "2022-04-07",
|
||||
runtime: 88,
|
||||
genres: ["Dokumentation"],
|
||||
voteAverage: 7.7,
|
||||
providers: [{ name: "ARTE", kind: "FREE" }],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_TITLES: DemoTitle[] = [...DEMO_SERIES, ...DEMO_MOVIES];
|
||||
|
||||
/** TMDB-IDs der Demo-Titel liegen bewusst weit oberhalb echter IDs. */
|
||||
export const DEMO_TMDB_BASE = 9_100_000;
|
||||
|
||||
export function demoTmdbId(key: string): number {
|
||||
const index = DEMO_TITLES.findIndex((title) => title.key === key);
|
||||
return DEMO_TMDB_BASE + (index >= 0 ? index : 999);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Erzeugt Poster und Hintergrundbilder für die Demo-Titel.
|
||||
*
|
||||
* Warum überhaupt: Flimmer ist eine posterlastige Anwendung. Ohne Bilder sieht
|
||||
* die Beispielbibliothek aus wie ein Fehler statt wie eine Bibliothek. Echte
|
||||
* TMDB-Poster lassen sich nicht mitliefern (Rechte, und es gäbe sie ohne
|
||||
* Schlüssel gar nicht), also entstehen hier abstrakte Poster im Flimmer-Look —
|
||||
* warmes Glühen auf dunklem Grund, Titel darauf.
|
||||
*
|
||||
* Die Dateien landen direkt im Bild-Cache unter demselben Namen, den
|
||||
* `src/lib/images.ts` berechnen würde. Damit funktioniert die Auslieferung über
|
||||
* /api/image ohne jede Sonderbehandlung.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
/** Muss zu `cacheFile()` in src/lib/images.ts passen. */
|
||||
function cacheFileName(size: string, path: string): string {
|
||||
const key = createHash("sha256").update(`${size}${path}`).digest("hex");
|
||||
return `${key}${path.slice(path.lastIndexOf("."))}`;
|
||||
}
|
||||
|
||||
/** Deterministischer Farbton je Titel — derselbe Titel bekommt immer dasselbe Poster. */
|
||||
function hue(seed: string): number {
|
||||
const hash = createHash("sha256").update(seed).digest();
|
||||
return (hash[0]! << 8 | hash[1]!) % 360;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Bricht den Titel auf mehrere Zeilen um, damit er auf das Poster passt. */
|
||||
function wrap(text: string, maxChars: number, maxLines: number): string[] {
|
||||
const words = text.split(/\s+/);
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
|
||||
for (const word of words) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (candidate.length <= maxChars) {
|
||||
current = candidate;
|
||||
} else {
|
||||
if (current) lines.push(current);
|
||||
current = word;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
|
||||
if (lines.length > maxLines) {
|
||||
const kept = lines.slice(0, maxLines);
|
||||
kept[maxLines - 1] = `${kept[maxLines - 1]!.slice(0, maxChars - 1)}…`;
|
||||
return kept;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
type PosterInput = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
seed: string;
|
||||
};
|
||||
|
||||
function posterSvg({ title, subtitle, seed }: PosterInput, width: number, height: number): string {
|
||||
const base = hue(seed);
|
||||
const warm = `hsl(${(base + 20) % 360} 92% 62%)`;
|
||||
const cool = `hsl(${(base + 320) % 360} 88% 58%)`;
|
||||
const scale = width / 342;
|
||||
|
||||
const fontSize = Math.round(30 * scale);
|
||||
const lines = wrap(title, 17, 3);
|
||||
const blockBottom = height - Math.round(52 * scale);
|
||||
const firstLineY = blockBottom - (lines.length - 1) * Math.round(fontSize * 1.14);
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="${width}" y2="${height}" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#171227"/>
|
||||
<stop offset="1" stop-color="#0a0810"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="${width * 0.72}" cy="${height * 0.24}" r="${width * 0.85}" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="${warm}" stop-opacity="0.85"/>
|
||||
<stop offset="0.45" stop-color="${cool}" stop-opacity="0.32"/>
|
||||
<stop offset="1" stop-color="${cool}" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="shade" x1="0" y1="${height * 0.42}" x2="0" y2="${height}" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0a0810" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#0a0810" stop-opacity="0.92"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rule" x1="0" y1="0" x2="${width}" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="${warm}"/>
|
||||
<stop offset="1" stop-color="${cool}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="${width}" height="${height}" fill="url(#bg)"/>
|
||||
<rect width="${width}" height="${height}" fill="url(#glow)"/>
|
||||
|
||||
<g opacity="0.16" stroke="${warm}" fill="none" stroke-width="${1.4 * scale}">
|
||||
${Array.from({ length: 7 }, (_, index) => {
|
||||
const r = (0.18 + index * 0.13) * width;
|
||||
return `<circle cx="${width * 0.72}" cy="${height * 0.26}" r="${r.toFixed(1)}"/>`;
|
||||
}).join("\n ")}
|
||||
</g>
|
||||
|
||||
<rect width="${width}" height="${height}" fill="url(#shade)"/>
|
||||
<rect x="${24 * scale}" y="${blockBottom - fontSize * lines.length - 22 * scale}" width="${44 * scale}" height="${3 * scale}" rx="${1.5 * scale}" fill="url(#rule)"/>
|
||||
|
||||
<text x="${24 * scale}" y="${firstLineY}" font-family="Segoe UI, Inter, Helvetica, Arial, sans-serif" font-size="${fontSize}" font-weight="700" fill="#f4f1fa">
|
||||
${lines
|
||||
.map(
|
||||
(line, index) =>
|
||||
`<tspan x="${24 * scale}" dy="${index === 0 ? 0 : Math.round(fontSize * 1.14)}">${escapeXml(line)}</tspan>`,
|
||||
)
|
||||
.join("\n ")}
|
||||
</text>
|
||||
<text x="${24 * scale}" y="${blockBottom + Math.round(26 * scale)}" font-family="Segoe UI, Inter, Helvetica, Arial, sans-serif" font-size="${Math.round(15 * scale)}" fill="#aca5c2">${escapeXml(subtitle)}</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function backdropSvg({ seed }: PosterInput, width: number, height: number): string {
|
||||
const base = hue(seed);
|
||||
const warm = `hsl(${(base + 20) % 360} 92% 62%)`;
|
||||
const cool = `hsl(${(base + 320) % 360} 88% 58%)`;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="${width}" y2="${height}" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#171227"/>
|
||||
<stop offset="1" stop-color="#0a0810"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="${width * 0.3}" cy="${height * 0.2}" r="${width * 0.7}" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="${warm}" stop-opacity="0.7"/>
|
||||
<stop offset="0.5" stop-color="${cool}" stop-opacity="0.28"/>
|
||||
<stop offset="1" stop-color="${cool}" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="${width}" height="${height}" fill="url(#bg)"/>
|
||||
<rect width="${width}" height="${height}" fill="url(#glow)"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/** Größen, die das UI anfragt. */
|
||||
const POSTER_SIZES: { size: string; width: number }[] = [
|
||||
{ size: "w92", width: 92 },
|
||||
{ size: "w154", width: 154 },
|
||||
{ size: "w185", width: 185 },
|
||||
{ size: "w342", width: 342 },
|
||||
{ size: "w500", width: 500 },
|
||||
];
|
||||
|
||||
const BACKDROP_SIZES: { size: string; width: number }[] = [
|
||||
{ size: "w780", width: 780 },
|
||||
{ size: "w1280", width: 1280 },
|
||||
];
|
||||
|
||||
export type GeneratedArt = { posterPath: string; backdropPath: string };
|
||||
|
||||
/**
|
||||
* Erzeugt Poster und Backdrop für einen Titel und legt sie im Bild-Cache ab.
|
||||
* Zurück kommen die TMDB-artigen Pfade, die in der Datenbank landen.
|
||||
*/
|
||||
export async function generateArtwork(
|
||||
dataDir: string,
|
||||
input: PosterInput,
|
||||
): Promise<GeneratedArt> {
|
||||
const stem = createHash("sha256").update(input.seed).digest("hex").slice(0, 24);
|
||||
const posterPath = `/${stem}p.jpg`;
|
||||
const backdropPath = `/${stem}b.jpg`;
|
||||
|
||||
const dir = join(dataDir, "images");
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
for (const { size, width } of POSTER_SIZES) {
|
||||
const height = Math.round(width * 1.5);
|
||||
const svg = posterSvg(input, width, height);
|
||||
const buffer = await sharp(Buffer.from(svg)).jpeg({ quality: 88 }).toBuffer();
|
||||
await writeFile(join(dir, cacheFileName(size, posterPath)), buffer);
|
||||
}
|
||||
|
||||
for (const { size, width } of BACKDROP_SIZES) {
|
||||
const height = Math.round((width * 9) / 16);
|
||||
const svg = backdropSvg(input, width, height);
|
||||
const buffer = await sharp(Buffer.from(svg)).jpeg({ quality: 84 }).toBuffer();
|
||||
await writeFile(join(dir, cacheFileName(size, backdropPath)), buffer);
|
||||
}
|
||||
|
||||
return { posterPath, backdropPath };
|
||||
}
|
||||
@@ -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");
|
||||
@@ -0,0 +1,25 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Passkey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"credentialId" TEXT NOT NULL,
|
||||
"publicKey" BYTEA NOT NULL,
|
||||
"counter" BIGINT NOT NULL DEFAULT 0,
|
||||
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"deviceType" TEXT NOT NULL DEFAULT 'singleDevice',
|
||||
"backedUp" BOOLEAN NOT NULL DEFAULT false,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Passkey_credentialId_key" ON "Passkey"("credentialId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Passkey_userId_idx" ON "Passkey"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WatchSource" AS ENUM ('FLIMMER', 'JELLYFIN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MediaServerKind" AS ENUM ('JELLYFIN', 'EMBY');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Watch" ADD COLUMN "source" "WatchSource" NOT NULL DEFAULT 'FLIMMER';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MediaServerConnection" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"kind" "MediaServerKind" NOT NULL DEFAULT 'JELLYFIN',
|
||||
"name" TEXT NOT NULL DEFAULT 'Jellyfin',
|
||||
"baseUrl" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"serverUserId" TEXT NOT NULL,
|
||||
"serverUserName" TEXT NOT NULL DEFAULT '',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"syncOwned" BOOLEAN NOT NULL DEFAULT true,
|
||||
"syncWatched" BOOLEAN NOT NULL DEFAULT true,
|
||||
"writeBack" BOOLEAN NOT NULL DEFAULT false,
|
||||
"lastSyncAt" TIMESTAMP(3),
|
||||
"lastSyncReport" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MediaServerConnection_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MediaServerItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"connectionId" TEXT NOT NULL,
|
||||
"externalId" TEXT NOT NULL,
|
||||
"titleId" TEXT,
|
||||
"episodeId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MediaServerItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MediaServerConnection_userId_idx" ON "MediaServerConnection"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MediaServerConnection_userId_baseUrl_key" ON "MediaServerConnection"("userId", "baseUrl");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MediaServerItem_connectionId_idx" ON "MediaServerItem"("connectionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MediaServerItem_connectionId_externalId_key" ON "MediaServerItem"("connectionId", "externalId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MediaServerConnection" ADD CONSTRAINT "MediaServerConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MediaServerItem" ADD CONSTRAINT "MediaServerItem_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MediaServerConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MediaServerItem" ADD CONSTRAINT "MediaServerItem_titleId_fkey" FOREIGN KEY ("titleId") REFERENCES "Title"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MediaServerItem" ADD CONSTRAINT "MediaServerItem_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "Episode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -53,6 +53,8 @@ model User {
|
||||
sessions Session[]
|
||||
apiKeys ApiKey[]
|
||||
oidcAccounts OidcAccount[]
|
||||
passkeys Passkey[]
|
||||
mediaServers MediaServerConnection[]
|
||||
entries LibraryEntry[]
|
||||
watches Watch[]
|
||||
tags Tag[]
|
||||
@@ -109,6 +111,37 @@ model OidcAccount {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Ein Passkey (WebAuthn) als dritter Anmeldeweg neben Passwort und OIDC.
|
||||
model Passkey {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Kennung des Authenticators, base64url — die Gegenstelle nennt sie beim Anmelden.
|
||||
credentialId String @unique
|
||||
/// Öffentlicher Schlüssel im COSE-Format.
|
||||
publicKey Bytes
|
||||
|
||||
/// Signaturzähler. Zählt er nicht hoch, wurde die Antwort wiedereingespielt.
|
||||
counter BigInt @default(0)
|
||||
|
||||
/// usb, nfc, ble, internal, hybrid — hilft dem Browser bei der Auswahl.
|
||||
transports String[] @default([])
|
||||
|
||||
/// singleDevice oder multiDevice. Ein nicht gesicherter Passkey ist mit dem
|
||||
/// Gerät verloren — davor warnt die Oberfläche.
|
||||
deviceType String @default("singleDevice")
|
||||
backedUp Boolean @default(false)
|
||||
|
||||
/// Vom Nutzer vergeben: „iPhone", „Yubikey am Schlüsselbund".
|
||||
name String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Instanzweite Einstellungen (Registrierung offen/zu, Instanzname …)
|
||||
model AppSetting {
|
||||
key String @id
|
||||
@@ -175,6 +208,7 @@ model Title {
|
||||
watches Watch[]
|
||||
providers TitleProvider[]
|
||||
universeItems UniverseItem[]
|
||||
serverItems MediaServerItem[]
|
||||
|
||||
@@unique([type, tmdbId])
|
||||
@@index([type])
|
||||
@@ -219,6 +253,7 @@ model Episode {
|
||||
runtime Int?
|
||||
|
||||
watches Watch[]
|
||||
serverItems MediaServerItem[]
|
||||
|
||||
@@unique([titleId, seasonNumber, episodeNumber])
|
||||
@@index([titleId])
|
||||
@@ -289,6 +324,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 +351,7 @@ model LibraryEntry {
|
||||
@@unique([userId, titleId])
|
||||
@@index([userId, status])
|
||||
@@index([userId, updatedAt])
|
||||
@@index([userId, owned])
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
@@ -351,6 +394,11 @@ model Watch {
|
||||
run Int @default(0)
|
||||
watchedAt DateTime @default(now())
|
||||
|
||||
/// Woher die Sichtung stammt. Ohne diese Angabe liesse sich spaeter nicht
|
||||
/// mehr sagen, was von einem Medienserver kam — und ein Zuruecknehmen des
|
||||
/// Abgleichs waere unmoeglich.
|
||||
source WatchSource @default(FLIMMER)
|
||||
|
||||
/// Tatsächlich angerechnete Minuten — friert die Laufzeit zum Zeitpunkt der
|
||||
/// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen.
|
||||
minutes Int @default(0)
|
||||
@@ -508,3 +556,75 @@ model SyncRun {
|
||||
|
||||
@@index([kind, startedAt])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Medienserver (Jellyfin, Emby)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum WatchSource {
|
||||
FLIMMER
|
||||
JELLYFIN
|
||||
}
|
||||
|
||||
enum MediaServerKind {
|
||||
JELLYFIN
|
||||
EMBY
|
||||
}
|
||||
|
||||
/// Verbindung zu einem Medienserver. Je Nutzer und Server eine.
|
||||
model MediaServerConnection {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
kind MediaServerKind @default(JELLYFIN)
|
||||
/// Anzeigename des Servers, wie ihn Jellyfin meldet.
|
||||
name String @default("Jellyfin")
|
||||
baseUrl String
|
||||
|
||||
/// Zugangstoken, mit AUTH_SECRET verschluesselt — im Klartext hat er hier
|
||||
/// nichts verloren.
|
||||
token String
|
||||
/// Kennung des Jellyfin-Nutzers, dessen Bestand und Sichtungen zaehlen.
|
||||
serverUserId String
|
||||
/// Anmeldename, nur zur Anzeige.
|
||||
serverUserName String @default("")
|
||||
|
||||
enabled Boolean @default(true)
|
||||
syncOwned Boolean @default(true)
|
||||
syncWatched Boolean @default(true)
|
||||
/// Stufe 3: Haekchen aus Flimmer zurueck nach Jellyfin schreiben.
|
||||
writeBack Boolean @default(false)
|
||||
|
||||
lastSyncAt DateTime?
|
||||
lastSyncReport Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
items MediaServerItem[]
|
||||
|
||||
@@unique([userId, baseUrl])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Bereits zugeordnete Items. Macht Folgelaeufe billig und ist die
|
||||
/// Voraussetzung fuers Zurueckschreiben.
|
||||
model MediaServerItem {
|
||||
id String @id @default(cuid())
|
||||
connectionId String
|
||||
connection MediaServerConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Item-Kennung auf dem Medienserver.
|
||||
externalId String
|
||||
|
||||
titleId String?
|
||||
title Title? @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||||
episodeId String?
|
||||
episode Episode? @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([connectionId, externalId])
|
||||
@@index([connectionId])
|
||||
}
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
*
|
||||
* npm run db:seed
|
||||
*
|
||||
* Das Skript ist idempotent: mehrfaches Ausführen legt nichts doppelt an.
|
||||
* Es fasst ausschließlich die Demo-Konten und deren Daten an — eine echte
|
||||
* Instanz nimmt dadurch keinen Schaden, sollte es versehentlich laufen.
|
||||
* Legt zwei Demo-Konten, einen erfundenen Katalog mit lokal erzeugten Postern
|
||||
* und eine gelebte Bibliothek an: laufende Serien, abgeschlossene, ein
|
||||
* Rewatch, eine Watchlist und Sichtungen über die letzten Monate verteilt —
|
||||
* damit Übersicht, Kalender und Statistik etwas zu zeigen haben.
|
||||
*
|
||||
* Das Skript ist idempotent und fasst ausschließlich die Demo-Daten an
|
||||
* (Konten auf @flimmer.local, Titel mit TMDB-IDs ab 9.100.000).
|
||||
*/
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { PrismaClient } from "../src/generated/prisma/client";
|
||||
import { DEMO_MOVIES, DEMO_SERIES, demoTmdbId, type DemoMovie, type DemoSeries } from "./demo/catalog";
|
||||
import { generateArtwork } from "./demo/posters";
|
||||
|
||||
try {
|
||||
process.loadEnvFile(".env");
|
||||
@@ -22,8 +28,514 @@ const prisma = new PrismaClient({
|
||||
});
|
||||
|
||||
const DEMO_PASSWORD = "flimmer-demo-2026";
|
||||
const DATA_DIR = process.env.DATA_DIR || "./data";
|
||||
const REGION = process.env.TMDB_REGION || "DE";
|
||||
|
||||
async function seedUsers() {
|
||||
const PROVIDER_SEED: Record<string, { tmdbId: number; color: string }> = {
|
||||
Netflix: { tmdbId: 8, color: "#e50914" },
|
||||
"Amazon Prime Video": { tmdbId: 119, color: "#00a8e1" },
|
||||
"Disney Plus": { tmdbId: 337, color: "#0e47a1" },
|
||||
"Apple TV+": { tmdbId: 350, color: "#8f8f8f" },
|
||||
WOW: { tmdbId: 30, color: "#00a4e4" },
|
||||
Joyn: { tmdbId: 305, color: "#f5008c" },
|
||||
"ARD Mediathek": { tmdbId: 216, color: "#0a3b78" },
|
||||
"ZDF Mediathek": { tmdbId: 217, color: "#fa7d19" },
|
||||
ARTE: { tmdbId: 234, color: "#ff6600" },
|
||||
"Google Play Movies": { tmdbId: 3, color: "#34a853" },
|
||||
};
|
||||
|
||||
function date(value: string): Date {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** Tage vor heute — für Sichtungen, die sich über die Zeit verteilen sollen. */
|
||||
function daysAgo(days: number, hour = 21): Date {
|
||||
const result = new Date();
|
||||
result.setDate(result.getDate() - days);
|
||||
result.setHours(hour, 15, 0, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Katalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedProviders() {
|
||||
const map = new Map<string, string>();
|
||||
for (const [name, info] of Object.entries(PROVIDER_SEED)) {
|
||||
const row = await prisma.watchProvider.upsert({
|
||||
where: { tmdbId: info.tmdbId },
|
||||
// logoPath bleibt leer: für die erfundenen Demo-Dienste gibt es keine
|
||||
// echten Logos, und ein toter Pfad löst bei jedem Aufruf eine 404 aus.
|
||||
create: { tmdbId: info.tmdbId, name, color: info.color, logoPath: null },
|
||||
update: { name, color: info.color, logoPath: null },
|
||||
select: { id: true },
|
||||
});
|
||||
map.set(name, row.id);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function linkProviders(
|
||||
titleId: string,
|
||||
providers: { name: string; kind: string }[],
|
||||
providerIds: Map<string, string>,
|
||||
) {
|
||||
for (const entry of providers) {
|
||||
const providerId = providerIds.get(entry.name);
|
||||
if (!providerId) continue;
|
||||
await prisma.titleProvider.upsert({
|
||||
where: {
|
||||
titleId_providerId_region_kind: {
|
||||
titleId,
|
||||
providerId,
|
||||
region: REGION,
|
||||
kind: entry.kind as "FLATRATE",
|
||||
},
|
||||
},
|
||||
create: { titleId, providerId, region: REGION, kind: entry.kind as "FLATRATE" },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function seedSeries(series: DemoSeries, providerIds: Map<string, string>) {
|
||||
const art = await generateArtwork(DATA_DIR, {
|
||||
title: series.title,
|
||||
subtitle: `${series.firstAirDate.slice(0, 4)} · ${series.genres[0] ?? "Serie"}`,
|
||||
seed: series.key,
|
||||
});
|
||||
|
||||
const tmdbId = demoTmdbId(series.key);
|
||||
const common = {
|
||||
title: series.title,
|
||||
originalTitle: series.originalTitle ?? null,
|
||||
overview: series.overview,
|
||||
tagline: series.tagline ?? null,
|
||||
posterPath: art.posterPath,
|
||||
backdropPath: art.backdropPath,
|
||||
releaseDate: date(series.firstAirDate),
|
||||
lastAirDate: series.lastAirDate ? date(series.lastAirDate) : null,
|
||||
runtime: series.runtime,
|
||||
status: series.status,
|
||||
inProduction: series.inProduction,
|
||||
genres: series.genres,
|
||||
originalLang: "de",
|
||||
voteAverage: series.voteAverage,
|
||||
syncedAt: new Date(),
|
||||
};
|
||||
|
||||
const title = await prisma.title.upsert({
|
||||
where: { type_tmdbId: { type: "SERIES", tmdbId } },
|
||||
create: { type: "SERIES", tmdbId, ...common },
|
||||
update: common,
|
||||
});
|
||||
|
||||
let episodeCount = 0;
|
||||
for (const season of series.seasons) {
|
||||
const seasonRow = await prisma.season.upsert({
|
||||
where: { titleId_seasonNumber: { titleId: title.id, seasonNumber: season.seasonNumber } },
|
||||
create: {
|
||||
titleId: title.id,
|
||||
seasonNumber: season.seasonNumber,
|
||||
name: season.name,
|
||||
airDate: date(season.airDate),
|
||||
episodeCount: season.episodes.length,
|
||||
posterPath: art.posterPath,
|
||||
},
|
||||
update: { name: season.name, episodeCount: season.episodes.length },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const [index, name] of season.episodes.entries()) {
|
||||
// Eine Episode pro Woche ab dem Staffelstart.
|
||||
const airDate = new Date(date(season.airDate).getTime() + index * 7 * 86_400_000);
|
||||
const payload = {
|
||||
seasonId: seasonRow.id,
|
||||
name,
|
||||
overview: null,
|
||||
airDate,
|
||||
runtime: season.runtime,
|
||||
};
|
||||
await prisma.episode.upsert({
|
||||
where: {
|
||||
titleId_seasonNumber_episodeNumber: {
|
||||
titleId: title.id,
|
||||
seasonNumber: season.seasonNumber,
|
||||
episodeNumber: index + 1,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
titleId: title.id,
|
||||
seasonNumber: season.seasonNumber,
|
||||
episodeNumber: index + 1,
|
||||
...payload,
|
||||
},
|
||||
update: payload,
|
||||
});
|
||||
episodeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.title.update({
|
||||
where: { id: title.id },
|
||||
data: { seasonCount: series.seasons.length, episodeCount },
|
||||
});
|
||||
|
||||
await linkProviders(title.id, series.providers, providerIds);
|
||||
return title;
|
||||
}
|
||||
|
||||
async function seedMovie(movie: DemoMovie, providerIds: Map<string, string>) {
|
||||
const art = await generateArtwork(DATA_DIR, {
|
||||
title: movie.title,
|
||||
subtitle: `${movie.releaseDate.slice(0, 4)} · ${movie.genres[0] ?? "Film"}`,
|
||||
seed: movie.key,
|
||||
});
|
||||
|
||||
const tmdbId = demoTmdbId(movie.key);
|
||||
const collectionId = movie.collection ? 9_500_000 + (movie.collection.length % 100) : null;
|
||||
const common = {
|
||||
title: movie.title,
|
||||
originalTitle: movie.originalTitle ?? null,
|
||||
overview: movie.overview,
|
||||
tagline: movie.tagline ?? null,
|
||||
posterPath: art.posterPath,
|
||||
backdropPath: art.backdropPath,
|
||||
releaseDate: date(movie.releaseDate),
|
||||
runtime: movie.runtime,
|
||||
status: "Released",
|
||||
genres: movie.genres,
|
||||
originalLang: "de",
|
||||
voteAverage: movie.voteAverage,
|
||||
tmdbCollectionId: collectionId,
|
||||
tmdbCollectionName: movie.collection ?? null,
|
||||
syncedAt: new Date(),
|
||||
};
|
||||
|
||||
const title = await prisma.title.upsert({
|
||||
where: { type_tmdbId: { type: "MOVIE", tmdbId } },
|
||||
create: { type: "MOVIE", tmdbId, ...common },
|
||||
update: common,
|
||||
});
|
||||
|
||||
await linkProviders(title.id, movie.providers, providerIds);
|
||||
return title;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bibliothek
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EntryPlan = {
|
||||
key: string;
|
||||
status: "WATCHING" | "PAUSED" | "COMPLETED" | "DROPPED" | "WATCHLIST";
|
||||
rating?: number;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
favorite?: boolean;
|
||||
/** Wie viele reguläre Episoden im aktuellen Durchlauf gesehen sind. */
|
||||
watchedEpisodes?: number;
|
||||
/** Vollständig gesehene frühere Durchläufe. */
|
||||
previousRuns?: number;
|
||||
/** Film gesehen? */
|
||||
seen?: boolean;
|
||||
/** Erste Sichtung vor wie vielen Tagen (die übrigen folgen im Abstand). */
|
||||
startedDaysAgo?: number;
|
||||
/** Abstand zwischen zwei Episoden in Tagen. */
|
||||
spacingDays?: number;
|
||||
/** Liegt der Titel in der eigenen Sammlung? */
|
||||
owned?: boolean;
|
||||
ownedSource?: string;
|
||||
};
|
||||
|
||||
const ENTRY_PLANS: EntryPlan[] = [
|
||||
{
|
||||
key: "nordlicht",
|
||||
status: "WATCHING",
|
||||
rating: 9,
|
||||
favorite: true,
|
||||
tags: ["abends", "mit anna"],
|
||||
notes: "Staffel 2 läuft noch — bloß nichts spoilern lassen.",
|
||||
watchedEpisodes: 10,
|
||||
startedDaysAgo: 120,
|
||||
spacingDays: 9,
|
||||
},
|
||||
{
|
||||
key: "kaffeesatz",
|
||||
status: "COMPLETED",
|
||||
rating: 8,
|
||||
tags: ["nebenbei"],
|
||||
watchedEpisodes: 28,
|
||||
startedDaysAgo: 300,
|
||||
spacingDays: 8,
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-serie",
|
||||
status: "COMPLETED",
|
||||
rating: 9,
|
||||
tags: ["sternenpfad"],
|
||||
watchedEpisodes: 16,
|
||||
startedDaysAgo: 210,
|
||||
spacingDays: 6,
|
||||
},
|
||||
{
|
||||
key: "sternenpfad-akademie",
|
||||
status: "WATCHING",
|
||||
tags: ["sternenpfad"],
|
||||
watchedEpisodes: 4,
|
||||
startedDaysAgo: 26,
|
||||
spacingDays: 6,
|
||||
},
|
||||
{
|
||||
key: "aktenlage",
|
||||
status: "PAUSED",
|
||||
rating: 7,
|
||||
notes: "Gut, aber ich brauche dafür Ruhe. Später weiterschauen.",
|
||||
watchedEpisodes: 4,
|
||||
startedDaysAgo: 95,
|
||||
spacingDays: 4,
|
||||
},
|
||||
{
|
||||
key: "tiefenschaerfe",
|
||||
status: "WATCHING",
|
||||
rating: 8,
|
||||
tags: ["mit anna"],
|
||||
notes: "Zweiter Durchlauf — diesmal auf die Fotos achten.",
|
||||
previousRuns: 1,
|
||||
watchedEpisodes: 2,
|
||||
startedDaysAgo: 14,
|
||||
spacingDays: 3,
|
||||
},
|
||||
{
|
||||
key: "gartenjahr",
|
||||
status: "WATCHING",
|
||||
rating: 7,
|
||||
tags: ["nebenbei"],
|
||||
watchedEpisodes: 8,
|
||||
startedDaysAgo: 70,
|
||||
spacingDays: 7,
|
||||
},
|
||||
{ key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] },
|
||||
|
||||
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340, 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"] },
|
||||
{
|
||||
key: "stille-bilder",
|
||||
status: "COMPLETED",
|
||||
rating: 8,
|
||||
seen: true,
|
||||
previousRuns: 1,
|
||||
notes: "Beim zweiten Mal deutlich besser.",
|
||||
startedDaysAgo: 180,
|
||||
},
|
||||
{ key: "stille-bilder-2", status: "WATCHLIST" },
|
||||
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60, 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 },
|
||||
];
|
||||
|
||||
async function seedLibrary(userId: string, titleIds: Map<string, string>) {
|
||||
for (const plan of ENTRY_PLANS) {
|
||||
const titleId = titleIds.get(plan.key);
|
||||
if (!titleId) continue;
|
||||
|
||||
const title = await prisma.title.findUniqueOrThrow({
|
||||
where: { id: titleId },
|
||||
select: { id: true, type: true, runtime: true },
|
||||
});
|
||||
|
||||
const currentRun = plan.previousRuns ?? 0;
|
||||
const entry = await prisma.libraryEntry.upsert({
|
||||
where: { userId_titleId: { userId, titleId } },
|
||||
create: {
|
||||
userId,
|
||||
titleId,
|
||||
status: plan.status,
|
||||
rating: plan.rating ?? null,
|
||||
notes: plan.notes ?? null,
|
||||
favorite: plan.favorite ?? false,
|
||||
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,
|
||||
},
|
||||
update: {
|
||||
status: plan.status,
|
||||
rating: plan.rating ?? null,
|
||||
notes: plan.notes ?? null,
|
||||
favorite: plan.favorite ?? false,
|
||||
owned: plan.owned ?? false,
|
||||
ownedSource: plan.ownedSource ?? null,
|
||||
currentRun,
|
||||
},
|
||||
});
|
||||
|
||||
// „Wo schaue ich das" aus der Verfügbarkeit vorbelegen
|
||||
const provider = await prisma.titleProvider.findFirst({
|
||||
where: { titleId, region: REGION, kind: { in: ["FLATRATE", "FREE", "ADS"] } },
|
||||
include: { provider: { select: { name: true } } },
|
||||
});
|
||||
if (provider) {
|
||||
await prisma.libraryEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { whereToWatch: provider.provider.name },
|
||||
});
|
||||
}
|
||||
|
||||
if (plan.tags?.length) {
|
||||
for (const name of plan.tags) {
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { userId_name: { userId, name } },
|
||||
create: { userId, name },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.tagOnEntry.upsert({
|
||||
where: { tagId_entryId: { tagId: tag.id, entryId: entry.id } },
|
||||
create: { tagId: tag.id, entryId: entry.id },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.watch.deleteMany({ where: { userId, entryId: entry.id } });
|
||||
|
||||
if (title.type === "MOVIE") {
|
||||
if (!plan.seen) continue;
|
||||
for (let run = 0; run <= currentRun; run += 1) {
|
||||
await prisma.watch.create({
|
||||
data: {
|
||||
userId,
|
||||
entryId: entry.id,
|
||||
titleId,
|
||||
run,
|
||||
watchedAt: daysAgo((plan.startedDaysAgo ?? 30) - run * 25),
|
||||
minutes: title.runtime ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const episodes = await prisma.episode.findMany({
|
||||
where: { titleId, seasonNumber: { gt: 0 } },
|
||||
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||
select: { id: true, runtime: true },
|
||||
});
|
||||
|
||||
// Frühere Durchläufe: vollständig, weiter in der Vergangenheit.
|
||||
for (let run = 0; run < currentRun; run += 1) {
|
||||
for (const [index, episode] of episodes.entries()) {
|
||||
await prisma.watch.create({
|
||||
data: {
|
||||
userId,
|
||||
entryId: entry.id,
|
||||
titleId,
|
||||
episodeId: episode.id,
|
||||
run,
|
||||
watchedAt: daysAgo((plan.startedDaysAgo ?? 60) + 120 - index * 2),
|
||||
minutes: episode.runtime ?? title.runtime ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const watched = episodes.slice(0, plan.watchedEpisodes ?? 0);
|
||||
const start = plan.startedDaysAgo ?? 60;
|
||||
const spacing = plan.spacingDays ?? 5;
|
||||
|
||||
for (const [index, episode] of watched.entries()) {
|
||||
await prisma.watch.create({
|
||||
data: {
|
||||
userId,
|
||||
entryId: entry.id,
|
||||
titleId,
|
||||
episodeId: episode.id,
|
||||
run: currentRun,
|
||||
watchedAt: daysAgo(Math.max(0, start - index * spacing)),
|
||||
minutes: episode.runtime ?? title.runtime ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Universum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Die Erscheinungsreihenfolge berechnet Flimmer selbst — hier steht nur die
|
||||
* abweichende Story-Chronologie, damit die Seite zwei echte Reihenfolgen zeigt.
|
||||
*/
|
||||
const CHRONOLOGICAL = [
|
||||
"sternenpfad-null",
|
||||
"sternenpfad-1",
|
||||
"sternenpfad-akademie",
|
||||
"sternenpfad-2",
|
||||
"sternenpfad-serie",
|
||||
"sternenpfad-3",
|
||||
"sternenpfad-4",
|
||||
];
|
||||
|
||||
async function seedUniverse(ownerId: string, titleIds: Map<string, string>) {
|
||||
const existing = await prisma.universe.findUnique({
|
||||
where: { ownerId_slug: { ownerId, slug: "sternenpfad" } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.universe.delete({ where: { id: existing.id } });
|
||||
}
|
||||
|
||||
const universe = await prisma.universe.create({
|
||||
data: {
|
||||
ownerId,
|
||||
name: "Sternenpfad",
|
||||
slug: "sternenpfad",
|
||||
description:
|
||||
"Fünf Filme und zwei Serien. Die Kinoreihenfolge und die Story-Chronologie gehen auseinander — deshalb zwei Reihenfolgen.",
|
||||
orders: {
|
||||
create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }],
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const itemIdByKey = new Map<string, string>();
|
||||
for (const key of CHRONOLOGICAL) {
|
||||
const titleId = titleIds.get(key);
|
||||
if (!titleId) continue;
|
||||
const item = await prisma.universeItem.create({
|
||||
data: { universeId: universe.id, titleId },
|
||||
select: { id: true },
|
||||
});
|
||||
itemIdByKey.set(key, item.id);
|
||||
}
|
||||
|
||||
const order = await prisma.universeOrder.create({
|
||||
data: { universeId: universe.id, name: "Chronologisch", kind: "CUSTOM" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.universeOrderItem.createMany({
|
||||
data: CHRONOLOGICAL.flatMap((key, index) => {
|
||||
const itemId = itemIdByKey.get(key);
|
||||
return itemId ? [{ orderId: order.id, itemId, position: index }] : [];
|
||||
}),
|
||||
});
|
||||
|
||||
return universe;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
console.log("Seed: Demo-Konten ...");
|
||||
const passwordHash = await bcrypt.hash(DEMO_PASSWORD, 12);
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
@@ -34,7 +546,7 @@ async function seedUsers() {
|
||||
name: "Demo Nutzer",
|
||||
passwordHash,
|
||||
role: "ADMIN",
|
||||
region: "DE",
|
||||
region: REGION,
|
||||
publicProfile: true,
|
||||
profileSlug: "demo",
|
||||
},
|
||||
@@ -48,19 +560,44 @@ async function seedUsers() {
|
||||
name: "Gast Nutzerin",
|
||||
passwordHash,
|
||||
role: "USER",
|
||||
region: "DE",
|
||||
region: REGION,
|
||||
},
|
||||
});
|
||||
|
||||
return { admin, friend };
|
||||
}
|
||||
console.log("Seed: Streamingdienste ...");
|
||||
const providerIds = await seedProviders();
|
||||
|
||||
async function main() {
|
||||
console.log("Seed: lege Demo-Konten an ...");
|
||||
const { admin, friend } = await seedUsers();
|
||||
console.log("Seed: Katalog mit Postern ...");
|
||||
const titleIds = new Map<string, string>();
|
||||
for (const series of DEMO_SERIES) {
|
||||
const row = await seedSeries(series, providerIds);
|
||||
titleIds.set(series.key, row.id);
|
||||
console.log(` Serie ${series.title}`);
|
||||
}
|
||||
for (const movie of DEMO_MOVIES) {
|
||||
const row = await seedMovie(movie, providerIds);
|
||||
titleIds.set(movie.key, row.id);
|
||||
console.log(` Film ${movie.title}`);
|
||||
}
|
||||
|
||||
console.log("Seed: Bibliothek und Sichtungen ...");
|
||||
await seedLibrary(admin.id, titleIds);
|
||||
|
||||
console.log("Seed: Universum ...");
|
||||
const universe = await seedUniverse(admin.id, titleIds);
|
||||
// Mit dem zweiten Konto teilen, damit die Freigabe sichtbar ist.
|
||||
await prisma.universeMember.upsert({
|
||||
where: { universeId_userId: { universeId: universe.id, userId: friend.id } },
|
||||
create: { universeId: universe.id, userId: friend.id, permission: "READ" },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const watches = await prisma.watch.count({ where: { userId: admin.id } });
|
||||
const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } });
|
||||
|
||||
console.log("");
|
||||
console.log("Fertig. Anmelden mit:");
|
||||
console.log(`Fertig: ${entries} Einträge, ${watches} Sichtungen.`);
|
||||
console.log("Anmelden mit:");
|
||||
console.log(` ${admin.email} / ${DEMO_PASSWORD} (Administrator)`);
|
||||
console.log(` ${friend.email} / ${DEMO_PASSWORD}`);
|
||||
}
|
||||
|
||||
@@ -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,33 @@
|
||||
# Flimmer 0.2.0
|
||||
|
||||
Metadaten: Flimmer spricht mit TMDB.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **TMDB-Anbindung** für Suche (gemeinsam über Serien und Filme oder getrennt),
|
||||
Detailabruf, Staffeln und Episoden, offizielle Collections sowie die
|
||||
Streaming-Verfügbarkeit in der eingestellten Region. Zugang wahlweise über
|
||||
den klassischen API-Schlüssel oder das Bearer-Token.
|
||||
- **Gemeinsamer Katalog**: Titel werden über (Typ, TMDB-ID) dedupliziert — bei
|
||||
mehreren Nutzern liegt jede Serie samt Episodenliste nur einmal in der
|
||||
Datenbank. `ensureTitle` holt einen Titel nur, wenn er fehlt oder veraltet ist,
|
||||
und fällt bei einem TMDB-Ausfall auf den lokalen Stand zurück.
|
||||
- **Bild-Cache**: Poster und Hintergrundbilder werden einmal geholt und danach
|
||||
über `/api/image` vom eigenen Server ausgeliefert. Größe und Pfad werden
|
||||
streng geprüft, damit der Endpunkt kein offener Proxy wird.
|
||||
- **Suchseite** mit Typfilter; die Anfrage steht in der URL und ist damit
|
||||
teilbar. Getippte Eingaben werden kurz gesammelt, bevor gesucht wird.
|
||||
- **Detailseite** je Titel mit Backdrop, Kopfdaten, Genres, Streamingdiensten
|
||||
(mit den Akzentfarben der großen Anbieter) und aufklappbaren Staffeln samt
|
||||
Episodenliste.
|
||||
- **Testlauf ohne Schlüssel**: nachgebildete TMDB-Antworten unter `tests/` und
|
||||
`npm test` prüfen den kompletten Importpfad gegen eine echte Datenbank.
|
||||
|
||||
### Behoben
|
||||
|
||||
- `importSeries` gab den Titel zurück, bevor Staffel- und Episodenzähler
|
||||
geschrieben waren — die Zähler standen dadurch auf 0.
|
||||
- Poster und Dienstlogos zeigten das kaputte Bildsymbol des Browsers, wenn ein
|
||||
TMDB-Pfad ins Leere lief. Die Rückfalldarstellung liegt jetzt dauerhaft
|
||||
darunter statt an einem `onError`-Handler zu hängen, der bei einem früh
|
||||
gescheiterten Bild gar nicht mehr feuert.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Flimmer 0.3.0
|
||||
|
||||
Das Kernstück: Bibliothek, Status und Episodenfortschritt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Bibliothek** als Posterraster mit Volltextsuche und Filtern nach Status,
|
||||
Typ, Genre, Streamingdienst und Tag; Sortierung nach zuletzt aktualisiert,
|
||||
hinzugefügt, Titel, Bewertung oder Erscheinungsjahr. Der gesamte Filterstand
|
||||
steht in der URL und ist damit teilbar.
|
||||
- **Fünf Status** je Eintrag (Schaue gerade, Pausiert, Abgeschlossen,
|
||||
Abgebrochen, Watchlist). Der Status ergibt sich beim Abhaken von selbst:
|
||||
die erste Episode startet einen Titel, die letzte schließt ihn ab.
|
||||
Pausiert und Abgebrochen bleiben unangetastet — wer bewusst pausiert hat,
|
||||
will das nicht durch eine Sichtung aufgehoben bekommen.
|
||||
- **Episodenfortschritt**: einzeln abhaken, ganze Staffel auf einmal,
|
||||
„bis hierhin gesehen" für den Einstieg mitten in einer Staffel, und ein
|
||||
**„+1 Episode"**-Knopf, der genau die nächste offene Episode nimmt.
|
||||
Häkchen reagieren sofort und werden zurückgedreht, wenn der Server
|
||||
widerspricht; beim Abhaken glimmt die Zeile einmal warm auf.
|
||||
- **„Als Nächstes dran"** mit Episodencode, Titel und Ausstrahlungsdatum;
|
||||
Fortschritt in Prozent auf Basis der echten Episodenzahl, dazu die
|
||||
verbleibende Laufzeit.
|
||||
- **Rewatch-Zählung**: ein neuer Durchlauf setzt den Fortschritt zurück, die
|
||||
Sichtungen des vorherigen bleiben für die Statistik erhalten. Bei Filmen
|
||||
zählt jeder Durchlauf einzeln.
|
||||
- **Rückgängig** für jede Sichtungsaktion — die Rückmeldung am unteren Rand
|
||||
nennt, was passiert ist, und nimmt es auf Klick zurück.
|
||||
- **Pro Eintrag**: Bewertung von 1 bis 10, Notizfeld, eigene Tags und das Feld
|
||||
„wo schaue ich das", das beim Aufnehmen einen Vorschlag aus der
|
||||
Streaming-Verfügbarkeit erhält und jederzeit überschreibbar ist.
|
||||
- **Papierkorb** statt hartem Löschen: entfernte Titel bleiben 30 Tage liegen
|
||||
und behalten dabei ihren Fortschritt. Wird ein Titel erneut aufgenommen,
|
||||
lebt derselbe Eintrag wieder auf.
|
||||
- **Titel selbst anlegen** für alles, was TMDB nicht kennt. Solche Einträge
|
||||
haben keine TMDB-ID und werden vom Abgleich nie angefasst.
|
||||
- **Seed mit Beispielbibliothek**: erfundener Katalog aus 8 Serien und
|
||||
10 Filmen samt lokal erzeugten Postern, dazu 18 Bibliothekseinträge und
|
||||
85 Sichtungen über die letzten Monate verteilt.
|
||||
- **21 Tests** decken Aufnehmen, Abhaken, Fortschritt, Rewatch, Papierkorb und
|
||||
den Zugriffsschutz zwischen Nutzern ab.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Die Detailseite liegt jetzt unter `/titel/<id>` und hängt an der internen
|
||||
Titel-ID statt an der TMDB-ID — nur so bekommen manuell angelegte Titel
|
||||
dieselbe Seite. Treffer aus der Suche laufen über `/tmdb/<art>/<id>`, das
|
||||
den Titel holt und weiterleitet.
|
||||
- Die Zahl der Build-Worker ist auf vier begrenzt: mit einem Worker je Kern
|
||||
stürzte der Build auf einer 18-Kern-Maschine reproduzierbar ab, weil jeder
|
||||
Worker eine eigene Prisma-Engine hochzieht.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Flimmer 0.4.0
|
||||
|
||||
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
|
||||
Wizarding World oder was auch immer zusammengehört.
|
||||
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
|
||||
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
|
||||
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
|
||||
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
|
||||
Tastatur nicht bedienbar und auf dem Handy fummelig.
|
||||
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
|
||||
vorkommen, lassen sich mit einem Klick übernehmen.
|
||||
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
|
||||
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
|
||||
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
|
||||
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
|
||||
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
|
||||
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
|
||||
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
|
||||
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
|
||||
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
|
||||
Serien hinweg, Freigaben und die Rechteprüfung.
|
||||
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
|
||||
Serien und einer abweichenden Story-Chronologie.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
|
||||
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
|
||||
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
|
||||
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
|
||||
serverseitig.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Flimmer 0.5.0
|
||||
|
||||
Übersicht, Kalender und Statistik.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
|
||||
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
|
||||
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
|
||||
verfügbar geworden sind.
|
||||
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
|
||||
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
|
||||
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
|
||||
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
|
||||
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
|
||||
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
|
||||
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
|
||||
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
|
||||
zählen mit.
|
||||
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
|
||||
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
|
||||
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
|
||||
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
|
||||
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
|
||||
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
|
||||
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
|
||||
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
|
||||
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
|
||||
Standardton.
|
||||
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
|
||||
„Weiterschauen" und den Kalenderzeitraum.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
|
||||
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
|
||||
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
|
||||
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
|
||||
(„2026-04") aus der Datenbank.
|
||||
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
|
||||
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
|
||||
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
|
||||
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
|
||||
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
|
||||
hängt jetzt als `aria-label` am `<svg>`.
|
||||
@@ -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,25 @@
|
||||
# Flimmer 0.7.1
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
|
||||
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
|
||||
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
|
||||
wieder antwortet.
|
||||
|
||||
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
|
||||
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
|
||||
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
|
||||
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
|
||||
|
||||
Weitere Eigenheiten:
|
||||
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
|
||||
unterbleibt dann, statt sie zu überfahren.
|
||||
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
|
||||
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
|
||||
Healthcheck des Containers.
|
||||
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
|
||||
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
|
||||
|
||||
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
|
||||
Compose-Datei als Vorgabewert.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Flimmer 0.8.0
|
||||
|
||||
Zwei größere Bausteine: **Anmeldung per Passkey** und die **Jellyfin-Anbindung**.
|
||||
Dazu vier Nacharbeiten aus dem laufenden Betrieb.
|
||||
|
||||
> ⚠️ **Beim Aktualisieren kommen zwei Migrationen mit** (`passkeys`,
|
||||
> `medienserver`). Der Container spielt sie beim Start selbst ein; `./update.sh`
|
||||
> legt vorher eine Sicherung an.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Anmeldung per Passkey (WebAuthn).** Windows Hello, Face ID, Touch ID oder
|
||||
ein Sicherheitsschlüssel — statt Passwort. Anlegen und verwalten unter
|
||||
*Einstellungen → Passkeys*, anmelden über „Mit Passkey anmelden" **ohne
|
||||
vorherige E-Mail-Eingabe**.
|
||||
|
||||
Ein Passkey ist der dritte Weg in denselben Trichter: am Ende steht dieselbe
|
||||
Sitzung wie bei Passwort und OIDC. Das Passwort bleibt daneben bestehen, und
|
||||
den letzten verbleibenden Anmeldeweg lässt Flimmer nicht entfernen.
|
||||
|
||||
Drei Dinge, die beim Selbsthosten zählen:
|
||||
- **HTTPS ist Pflicht** (Ausnahme: `localhost`). Läuft die Instanz über
|
||||
einfaches HTTP, verschwindet die Verwaltung mit einem Hinweis, statt einen
|
||||
kryptischen Browserfehler zu zeigen.
|
||||
- Der Hostname kommt aus `APP_URL`, nicht aus dem `Host`-Header — hinter
|
||||
einem Reverse Proxy hinge er sonst am falschen Namen.
|
||||
- **Ein Domainwechsel entwertet alle Passkeys.** Sie sind fest an den
|
||||
Hostnamen gebunden und müssen danach neu angelegt werden.
|
||||
|
||||
Abschaltbar über `ENABLE_PASSKEYS`.
|
||||
|
||||
- **Jellyfin anbinden** — Bestand und Sichtungen übernehmen, unter
|
||||
*Einstellungen → Medienserver*.
|
||||
|
||||
Zugeordnet wird über die **TMDB-Kennung**, die Jellyfin zu jedem Item
|
||||
mitliefert; dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut. Kein
|
||||
Raten über Titel und Jahreszahlen — die Zuordnung ist eindeutig oder sie
|
||||
findet nicht statt. Was Jellyfin ohne Kennung führt, landet im Bericht.
|
||||
|
||||
Angemeldet wird mit Benutzername und Passwort, **nicht** mit einem
|
||||
API-Schlüssel: mit einem Schlüssel liefert Jellyfin den Bestand ohne
|
||||
Gesehen-Status, und zwar ohne Fehlermeldung. Der Verbindungstest prüft das
|
||||
ausdrücklich nach und sagt es. Das Zugangstoken liegt verschlüsselt in der
|
||||
Datenbank (AES-256-GCM, Schlüssel aus `AUTH_SECRET`), das Passwort wird nicht
|
||||
gespeichert.
|
||||
|
||||
Der Abgleich ist eine **Einbahnstraße**: er ergänzt und löscht nie etwas, das
|
||||
in Flimmer steht. Fehlende Titel werden nur mit gesetztem Häkchen
|
||||
aufgenommen — im täglichen Lauf also nie. Danach läuft er im
|
||||
Hintergrundlauf mit.
|
||||
|
||||
*Grenze der Quelle:* Jellyfin kennt `PlayCount` und `LastPlayedDate`, aber
|
||||
keine Liste einzelner Sichtungen. Ein dreimal gesehener Film kommt deshalb
|
||||
mit **einem** Termin an, nicht mit drei. Das steht auch in der Anleitung.
|
||||
|
||||
Das Zurückschreiben nach Jellyfin (Stufe 3) ist noch nicht dabei; das Feld
|
||||
liegt im Datenmodell bereit.
|
||||
|
||||
- **Standardreihenfolge je Universum.** Welche Reihenfolge beim Öffnen
|
||||
erscheint, lässt sich jetzt festlegen — wer Schreibrecht hat, setzt sie für
|
||||
alle. Die Rangfolge bleibt schlicht: Adresse schlägt Standard schlägt erste.
|
||||
Über die Schnittstelle mit `PATCH /api/v1/universen/{id}`.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Rückmeldung beim Tippen** in Suche und Bibliothek. Beim Suchen wird das
|
||||
Trefferraster gedämpft und auf `aria-busy` gesetzt, der Hinweis „sucht …"
|
||||
steht jetzt auch in der Bibliothek. Die alten Treffer bleiben sichtbar —
|
||||
sie verschwinden zu lassen ließe die Liste bei jedem Tastendruck springen.
|
||||
Die Sammelverzögerung liegt einheitlich bei 250 ms.
|
||||
|
||||
- **`update.sh` erkennt jetzt den Container-Zustand.** Schlägt die Abfrage über
|
||||
den veröffentlichten Port fehl, zählt der Healthcheck des Containers —
|
||||
hinter einem Reverse Proxy ist der App-Port oft gar nicht auf dem Host
|
||||
veröffentlicht, und das Skript meldete dann einen Fehlschlag, obwohl das
|
||||
Update sauber durchgelaufen war. Im Fehlerfall nennt es außerdem den Zustand
|
||||
und ordnet ihn ein.
|
||||
|
||||
*Zur Klarstellung:* Der Changelog zu 0.7.1 führte diese Rückfallprüfung
|
||||
bereits auf. Sie war dort beschrieben, aber nicht umgesetzt — jetzt ist sie
|
||||
es.
|
||||
|
||||
- Die Entwicklungsdatenbank hat einen eigenen Compose-Projektnamen
|
||||
(`flimmer-dev`). Vorher leiteten beide Compose-Dateien ihn aus dem
|
||||
Verzeichnis ab, und ein `docker compose down -v` auf dem Produktivstack nahm
|
||||
das Volume der Entwicklungsdatenbank mit.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Ein als gesehen markierter Film ließ sich nicht mehr zurücksetzen.** Im
|
||||
Zustand „gesehen" stand statt eines Knopfes ein Etikett ohne Klickziel, und
|
||||
eine Gegenfunktion gab es nicht — Serien hatten längst einen Umschalter,
|
||||
Filme nur einen Hinweg. Die grüne Plakette ist jetzt klickbar.
|
||||
Zurückgenommen wird dabei **nur der laufende Durchlauf**: wer einen Film
|
||||
dreimal gesehen hat, verliert die früheren Sichtungen nicht.
|
||||
|
||||
- **Die Beschriftungen im Titelraster sprangen**, sobald ein Titel zweizeilig
|
||||
umbrach — die Metazeile saß dann eine Zeilenhöhe tiefer als bei den Nachbarn,
|
||||
und die Reihen waren unterschiedlich hoch. Der Titelblock belegt jetzt immer
|
||||
zwei Zeilen; in der Bibliothek wird zusätzlich der Platz des
|
||||
Fortschrittsbalkens freigehalten, den es nur bei Serien gibt.
|
||||
|
||||
### Getestet
|
||||
|
||||
78 Tests (vorher 52). Neu dazu: Passkey-Verwaltung samt Aussperrsperre, der
|
||||
Jellyfin-Abgleich gegen nachgebildete Antworten, die Standardreihenfolge samt
|
||||
Eindeutigkeit, und das Zurücknehmen einer Filmsichtung über
|
||||
Durchlaufgrenzen hinweg.
|
||||
|
||||
Zusätzlich wurden die Klickwege gegen einen **Produktionsbau** geprüft — die
|
||||
Passkey-Anmeldung mit einem virtuellen Authenticator, der Jellyfin-Abgleich
|
||||
gegen einen nachgebildeten Server samt des Falls, in dem der Gesehen-Status
|
||||
fehlt.
|
||||
@@ -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,220 @@
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
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]")]);
|
||||
}
|
||||
|
||||
/** Screenshot eines einzelnen Abschnitts statt der ganzen Seite. */
|
||||
async function ausschnitt(page, name, ueberschrift) {
|
||||
// Einblendungen wegklicken — sie legen sich sonst über den Inhalt.
|
||||
const schliessen = page.locator("[aria-live] button");
|
||||
for (let i = await schliessen.count(); i > 0; i -= 1) {
|
||||
await schliessen.first().click().catch(() => {});
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const bereich = page.locator("section", { hasText: ueberschrift }).first();
|
||||
await bereich.screenshot({ path: `${OUT}/${name}.png` });
|
||||
console.log(" ", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Jellyfin, das gerade genug kann: anmelden, Systeminfo, Bestand.
|
||||
* Die TMDB-Kennungen zeigen auf Titel aus dem Seed, damit der Bericht echte
|
||||
* Zuordnungen zeigt. Alles auf ungesehen — der Screenshot soll den Bestand
|
||||
* zeigen, nicht die Sichtungen der Beispieldaten verbiegen.
|
||||
*/
|
||||
async function starteJellyfinNachbau() {
|
||||
const items = [
|
||||
{ Id: "jf-1", Name: "Die Stille zwischen den Bildern", Type: "Movie",
|
||||
ProviderIds: { Tmdb: "9100013" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
|
||||
{ Id: "jf-2", Name: "Die Stille zwischen den Bildern II", Type: "Movie",
|
||||
ProviderIds: { Tmdb: "9100014" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
|
||||
{ Id: "jf-3", Name: "Die Vermessung des Feierabends", Type: "Movie",
|
||||
ProviderIds: { Tmdb: "9100017" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
|
||||
{ Id: "jf-4", Name: "Urlaubsvideo 2019", Type: "Movie",
|
||||
ProviderIds: {}, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
|
||||
];
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://x");
|
||||
const json = (body) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
if (url.pathname === "/Users/AuthenticateByName") {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () =>
|
||||
json({ AccessToken: "screenshot-token", User: { Id: "jf-user", Name: "florian" } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/System/Info") return json({ ServerName: "Heimserver", Version: "10.10.3" });
|
||||
if (url.pathname === "/Items") return json({ Items: items, TotalRecordCount: items.length });
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise((fertig) => server.listen(0, "127.0.0.1", fertig));
|
||||
const port = server.address().port;
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise((fertig) => server.close(fertig)),
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// --- Zugang: Passkeys und Medienserver ---
|
||||
//
|
||||
// Beide brauchen Gegenstellen: einen Authenticator und einen Jellyfin-Server.
|
||||
// Statt echte Geräte vorauszusetzen, stellt das Skript sie selbst bereit —
|
||||
// der virtuelle Authenticator kommt vom Browser, der Medienserver ist ein
|
||||
// paar Zeilen HTTP hier drin. Beide Spuren werden am Ende wieder entfernt.
|
||||
{
|
||||
const jellyfin = await starteJellyfinNachbau();
|
||||
|
||||
const { ctx, page } = await session(1400, 1000, "dark");
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
await cdp.send("WebAuthn.enable");
|
||||
await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
||||
options: {
|
||||
protocol: "ctap2",
|
||||
transport: "internal",
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation: true,
|
||||
},
|
||||
});
|
||||
|
||||
await login(page);
|
||||
await page.goto(`${BASE}/einstellungen`, { waitUntil: "networkidle" });
|
||||
|
||||
// Passkey anlegen, damit die Liste nicht leer ist.
|
||||
await page.fill("input[aria-label='Name für den neuen Passkey']", "Mein Notebook");
|
||||
await page.getByRole("button", { name: "Passkey anlegen" }).click();
|
||||
await page.waitForTimeout(2500);
|
||||
await ausschnitt(page, "21-passkeys", "Passkeys");
|
||||
|
||||
// Jellyfin verbinden und einmal abgleichen.
|
||||
const medien = page.locator("section", { hasText: "Medienserver" });
|
||||
await page.fill("input[placeholder='http://jellyfin.fritz.box:8096']", jellyfin.url);
|
||||
await medien.locator("input").nth(1).fill("florian");
|
||||
await medien.locator("input").nth(2).fill("geheim");
|
||||
await page.getByRole("button", { name: "Verbinden" }).click();
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await page.getByRole("button", { name: "Jetzt abgleichen" }).click();
|
||||
await page.waitForTimeout(3500);
|
||||
await ausschnitt(page, "22-medienserver", "Medienserver");
|
||||
|
||||
// Spuren beseitigen: erst die Verbindung, dann den Passkey.
|
||||
await page.getByRole("button", { name: "Entfernen" }).first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
const passkeyWeg = page.locator("section", { hasText: "Passkeys" }).getByRole("button", { name: "Entfernen" });
|
||||
if (await passkeyWeg.count()) {
|
||||
await passkeyWeg.first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
await ctx.close();
|
||||
await jellyfin.close();
|
||||
}
|
||||
|
||||
// --- Hell ---
|
||||
{
|
||||
const { ctx, page } = await session(1400, 900, "light");
|
||||
await login(page);
|
||||
await shot(page, "08-hell", "/bibliothek");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// --- Mobil ---
|
||||
{
|
||||
const { ctx, page } = await session(430, 900, "dark");
|
||||
await login(page);
|
||||
await shot(page, "17-mobil-uebersicht", "/");
|
||||
await shot(page, "18-mobil-serie", "/bibliothek");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
console.log("Fehler:", errs.length ? [...new Set(errs)].slice(0, 5) : "keine");
|
||||
await browser.close();
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Kleiner TMDB-Ersatz für Screenshots und manuelles Ausprobieren.
|
||||
*
|
||||
* node scripts/tmdb-stub.mjs [port]
|
||||
*
|
||||
* Beantwortet die Suchendpunkte aus dem lokalen Katalog (den das Seed-Skript
|
||||
* angelegt hat) im Format der TMDB-API. Damit lässt sich die Suchseite auch
|
||||
* ohne echten TMDB-Schlüssel zeigen — für Screenshots der Anleitung und um
|
||||
* die Oberfläche auszuprobieren, bevor man einen Schlüssel besorgt.
|
||||
*
|
||||
* Verwendung zusammen mit:
|
||||
* TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "../src/generated/prisma/client.ts";
|
||||
|
||||
try {
|
||||
process.loadEnvFile(".env");
|
||||
} catch {
|
||||
// Werte kommen aus der Umgebung.
|
||||
}
|
||||
|
||||
const port = Number(process.argv[2] ?? 3099);
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
|
||||
function toResult(title, mediaType) {
|
||||
const date = title.releaseDate?.toISOString().slice(0, 10) ?? null;
|
||||
return {
|
||||
id: title.tmdbId,
|
||||
media_type: mediaType,
|
||||
name: mediaType === "tv" ? title.title : undefined,
|
||||
title: mediaType === "movie" ? title.title : undefined,
|
||||
original_name: title.originalTitle ?? undefined,
|
||||
original_title: title.originalTitle ?? undefined,
|
||||
overview: title.overview ?? "",
|
||||
poster_path: title.posterPath,
|
||||
backdrop_path: title.backdropPath,
|
||||
first_air_date: mediaType === "tv" ? date : undefined,
|
||||
release_date: mediaType === "movie" ? date : undefined,
|
||||
vote_average: title.voteAverage ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function search(query, type) {
|
||||
const titles = await prisma.title.findMany({
|
||||
where: {
|
||||
tmdbId: { not: null },
|
||||
...(type ? { type } : {}),
|
||||
OR: [
|
||||
{ title: { contains: query, mode: "insensitive" } },
|
||||
{ originalTitle: { contains: query, mode: "insensitive" } },
|
||||
{ genres: { has: query } },
|
||||
],
|
||||
},
|
||||
orderBy: { releaseDate: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return titles.map((title) => toResult(title, title.type === "SERIES" ? "tv" : "movie"));
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
|
||||
const query = url.searchParams.get("query") ?? "";
|
||||
|
||||
const send = (body, status = 200) => {
|
||||
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
try {
|
||||
if (url.pathname.startsWith("/3/search/")) {
|
||||
const kind = url.pathname.split("/").pop();
|
||||
const type = kind === "tv" ? "SERIES" : kind === "movie" ? "MOVIE" : null;
|
||||
const results = await search(query, type);
|
||||
return send({ page: 1, results, total_pages: 1, total_results: results.length });
|
||||
}
|
||||
|
||||
return send({ status_message: `Kein Ersatz für ${url.pathname}` }, 404);
|
||||
} catch (error) {
|
||||
return send({ status_message: String(error) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`TMDB-Ersatz läuft auf http://127.0.0.1:${port}/3`);
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
server.close();
|
||||
void prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { chromium } from "playwright";
|
||||
const [,, SP, ...specs] = process.argv;
|
||||
const b = await chromium.launch();
|
||||
const c = await b.newContext({ viewport: { width: 1280, height: 950 }, deviceScaleFactor: 2, colorScheme: "dark" });
|
||||
const p = await c.newPage();
|
||||
const errs = [];
|
||||
p.on("pageerror", e => errs.push(String(e).slice(0,200)));
|
||||
p.on("console", m => { if (m.type()==="error") errs.push("console: "+m.text().slice(0,160)); });
|
||||
await p.goto("http://localhost:3011/anmelden");
|
||||
await p.fill("#email", "demo@flimmer.local");
|
||||
await p.fill("#password", "flimmer-demo-2026");
|
||||
await Promise.all([p.waitForURL("http://localhost:3011/"), p.click("button[type=submit]")]);
|
||||
for (const spec of specs) {
|
||||
const [name, url, full] = spec.split("|");
|
||||
await p.goto(`http://localhost:3011${url}`, { waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(900);
|
||||
await p.screenshot({ path: `${SP}/${name}.png`, fullPage: full === "full" });
|
||||
}
|
||||
console.log("Fehler:", errs.length ? errs.slice(0,4) : "keine");
|
||||
await b.close();
|
||||
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/lib/auth";
|
||||
import { getSettings, isRegistrationOpen } from "@/lib/settings";
|
||||
import { env } from "@/lib/env";
|
||||
import { AuthSwitch, LoginForm } from "@/components/AuthForm";
|
||||
import { PasskeyLoginButton } from "@/components/settings/Passkeys";
|
||||
import { loginAction } from "../actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Anmelden" };
|
||||
@@ -36,16 +37,22 @@ export default async function LoginPage({
|
||||
|
||||
<LoginForm action={loginAction} weiter={params.weiter} />
|
||||
|
||||
{env.oidc.isConfigured && (
|
||||
{(env.passkeys.isAvailable || env.oidc.isConfigured) && (
|
||||
<>
|
||||
<div className="my-5 flex items-center gap-3 text-xs text-[var(--fl-faint)]">
|
||||
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
||||
oder
|
||||
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
||||
</div>
|
||||
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full">
|
||||
|
||||
<div className="space-y-2">
|
||||
{env.passkeys.isAvailable && <PasskeyLoginButton label="Mit Passkey anmelden" />}
|
||||
{env.oidc.isConfigured && (
|
||||
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full justify-center">
|
||||
{env.oidc.buttonLabel}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import {
|
||||
addToLibrary,
|
||||
LibraryError,
|
||||
markEpisodesWatched,
|
||||
markMovieWatched,
|
||||
markSeasonWatched,
|
||||
markUpTo,
|
||||
moveToTrash,
|
||||
purgeEntry,
|
||||
restoreFromTrash,
|
||||
setStatus,
|
||||
startRewatch,
|
||||
undoWatches,
|
||||
unmarkEpisodes,
|
||||
unmarkMovie,
|
||||
unmarkSeason,
|
||||
updateEntryDetails,
|
||||
watchNextEpisode,
|
||||
type WatchResult,
|
||||
} from "@/lib/library";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Server-Aktionen rund um die Bibliothek.
|
||||
*
|
||||
* Jede Aktion holt sich die Sitzung selbst — auf Angaben aus dem Formular ist
|
||||
* beim Zugriffsschutz kein Verlass. Zurück kommt immer dieselbe Hülle, damit
|
||||
* das UI Erfolg, Fehler und Rückgängig-Möglichkeit einheitlich behandeln kann.
|
||||
*/
|
||||
|
||||
export type ActionResult = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
|
||||
watchIds?: string[];
|
||||
};
|
||||
|
||||
async function run(
|
||||
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
|
||||
revalidate: string[] = [],
|
||||
): Promise<ActionResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const result = (await fn(user.id)) ?? {};
|
||||
for (const path of revalidate) revalidatePath(path, "layout");
|
||||
return { ok: true, message: result.message, watchIds: result.watchIds };
|
||||
} catch (error) {
|
||||
if (error instanceof LibraryError) return { ok: false, message: error.message };
|
||||
console.error("Bibliotheksaktion fehlgeschlagen:", error);
|
||||
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
|
||||
}
|
||||
}
|
||||
|
||||
const PATHS = ["/", "/bibliothek", "/titel", "/papierkorb", "/kalender", "/statistik"];
|
||||
|
||||
const cuid = z.string().min(1).max(64);
|
||||
const statusSchema = z.enum(["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aufnehmen und entfernen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function addTitleAction(titleId: string, status: string = "WATCHLIST") {
|
||||
return run(async (userId) => {
|
||||
const parsedStatus = statusSchema.parse(status);
|
||||
await addToLibrary(userId, cuid.parse(titleId), parsedStatus);
|
||||
return {
|
||||
message:
|
||||
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
|
||||
};
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function trashEntryAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
await moveToTrash(userId, cuid.parse(entryId));
|
||||
return { message: "In den Papierkorb gelegt" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function restoreEntryAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
await restoreFromTrash(userId, cuid.parse(entryId));
|
||||
return { message: "Wiederhergestellt" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function purgeEntryAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
await purgeEntry(userId, cuid.parse(entryId));
|
||||
return { message: "Endgültig gelöscht" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status und Angaben
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function setStatusAction(entryId: string, status: string) {
|
||||
return run(async (userId) => {
|
||||
await setStatus(userId, cuid.parse(entryId), statusSchema.parse(status));
|
||||
return { message: "Status geändert" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
const detailsSchema = z.object({
|
||||
rating: z.number().int().min(1).max(10).nullable().optional(),
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
whereToWatch: z.string().max(120).nullable().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
owned: z.boolean().optional(),
|
||||
ownedSource: z.string().max(80).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function updateEntryAction(entryId: string, data: unknown) {
|
||||
return run(async (userId) => {
|
||||
await updateEntryDetails(userId, cuid.parse(entryId), detailsSchema.parse(data));
|
||||
return { message: "Gespeichert" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function startRewatchAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
const entry = await startRewatch(userId, cuid.parse(entryId));
|
||||
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sichtungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toResult(result: WatchResult) {
|
||||
return { message: result.label, watchIds: result.watchIds };
|
||||
}
|
||||
|
||||
export async function toggleEpisodeAction(entryId: string, episodeId: string, watched: boolean) {
|
||||
return run(async (userId) => {
|
||||
if (watched) {
|
||||
return toResult(await markEpisodesWatched(userId, cuid.parse(entryId), [cuid.parse(episodeId)]));
|
||||
}
|
||||
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
|
||||
return { message: "Markierung entfernt" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
|
||||
return run(async (userId) => {
|
||||
if (watched) {
|
||||
return toResult(await markSeasonWatched(userId, cuid.parse(entryId), cuid.parse(seasonId)));
|
||||
}
|
||||
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
|
||||
return { message: "Staffel zurückgesetzt" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function markUpToAction(entryId: string, episodeId: string) {
|
||||
return run(
|
||||
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
|
||||
PATHS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function watchNextAction(entryId: string) {
|
||||
return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS);
|
||||
}
|
||||
|
||||
export async function markMovieAction(entryId: string) {
|
||||
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
|
||||
}
|
||||
|
||||
export async function unmarkMovieAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
await unmarkMovie(userId, cuid.parse(entryId));
|
||||
return { message: "Markierung entfernt" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function undoAction(entryId: string, watchIds: string[]) {
|
||||
return run(async (userId) => {
|
||||
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
|
||||
return { message: "Rückgängig gemacht" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function setTagsAction(entryId: string, tagNames: string[]) {
|
||||
return run(async (userId) => {
|
||||
const id = cuid.parse(entryId);
|
||||
const names = z
|
||||
.array(z.string().trim().min(1).max(40))
|
||||
.max(20)
|
||||
.parse(tagNames)
|
||||
.map((name) => name.toLocaleLowerCase("de-DE"));
|
||||
|
||||
const entry = await prisma.libraryEntry.findFirst({
|
||||
where: { id, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!entry) throw new LibraryError("Dieser Eintrag gehört nicht zu deiner Bibliothek.");
|
||||
|
||||
const tags = await Promise.all(
|
||||
names.map((name) =>
|
||||
prisma.tag.upsert({
|
||||
where: { userId_name: { userId, name } },
|
||||
create: { userId, name },
|
||||
update: {},
|
||||
select: { id: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.tagOnEntry.deleteMany({
|
||||
where: { entryId: id, tagId: { notIn: tags.map((tag) => tag.id) } },
|
||||
}),
|
||||
...tags.map((tag) =>
|
||||
prisma.tagOnEntry.upsert({
|
||||
where: { tagId_entryId: { tagId: tag.id, entryId: id } },
|
||||
create: { tagId: tag.id, entryId: id },
|
||||
update: {},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
return { message: "Tags gespeichert" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manuell angelegte Titel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const manualSchema = z.object({
|
||||
type: z.enum(["SERIES", "MOVIE"]),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
year: z.number().int().min(1880).max(2200).nullable().optional(),
|
||||
runtime: z.number().int().min(1).max(2000).nullable().optional(),
|
||||
overview: z.string().trim().max(4000).optional(),
|
||||
genres: z.array(z.string().trim().min(1).max(40)).max(10).optional(),
|
||||
status: statusSchema.default("WATCHLIST"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Legt einen Titel ohne TMDB-Treffer an.
|
||||
*
|
||||
* Für alles, was TMDB nicht kennt: Heimvideos, Vorträge, regionale
|
||||
* Produktionen. Solche Titel haben keine `tmdbId` und werden vom Sync-Lauf
|
||||
* daher nie angefasst.
|
||||
*/
|
||||
export async function createManualTitleAction(data: unknown): Promise<ActionResult & { titleId?: string }> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
const parsed = manualSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, message: "Bitte gib mindestens einen Titel an." };
|
||||
}
|
||||
|
||||
const { type, title, year, runtime, overview, genres, status } = parsed.data;
|
||||
|
||||
try {
|
||||
const created = await prisma.title.create({
|
||||
data: {
|
||||
type,
|
||||
title,
|
||||
overview: overview || null,
|
||||
runtime: runtime ?? null,
|
||||
releaseDate: year ? new Date(Date.UTC(year, 0, 1)) : null,
|
||||
genres: genres ?? [],
|
||||
status: "Manuell angelegt",
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await addToLibrary(user.id, created.id, status);
|
||||
for (const path of PATHS) revalidatePath(path, "layout");
|
||||
|
||||
return { ok: true, message: `„${title}" angelegt`, titleId: created.id };
|
||||
} catch (error) {
|
||||
console.error("Manuellen Titel anlegen fehlgeschlagen:", error);
|
||||
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
"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";
|
||||
import {
|
||||
connectJellyfin,
|
||||
deleteConnection,
|
||||
syncConnection,
|
||||
testConnection,
|
||||
updateConnection,
|
||||
type SyncReport,
|
||||
} from "@/lib/mediaserver";
|
||||
|
||||
/** 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]}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Medienserver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const verbindungSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1).max(300),
|
||||
username: z.string().trim().min(1).max(120),
|
||||
password: z.string().min(1).max(300),
|
||||
});
|
||||
|
||||
export type MediaServerResult = SettingsResult & {
|
||||
pruefung?: {
|
||||
serverName: string;
|
||||
version: string | null;
|
||||
itemCount: number;
|
||||
hasUserData: boolean;
|
||||
withTmdbId: number;
|
||||
};
|
||||
bericht?: SyncReport;
|
||||
};
|
||||
|
||||
export async function connectMediaServerAction(input: {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<MediaServerResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
const daten = verbindungSchema.safeParse(input);
|
||||
if (!daten.success) return { ok: false, message: "Adresse, Name und Passwort werden gebraucht." };
|
||||
|
||||
try {
|
||||
const { pruefung } = await connectJellyfin(user.id, daten.data);
|
||||
revalidatePath("/einstellungen", "layout");
|
||||
return {
|
||||
ok: true,
|
||||
message: `Verbunden mit ${pruefung.serverName}`,
|
||||
pruefung,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function testMediaServerAction(connectionId: string): Promise<MediaServerResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const pruefung = await testConnection(user.id, connectionId);
|
||||
return { ok: true, message: `${pruefung.serverName} antwortet`, pruefung };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncMediaServerAction(
|
||||
connectionId: string,
|
||||
aufnehmen: boolean,
|
||||
): Promise<MediaServerResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const bericht = await syncConnection(user.id, connectionId, { aufnehmen });
|
||||
revalidatePath("/einstellungen", "layout");
|
||||
revalidatePath("/bibliothek", "layout");
|
||||
return {
|
||||
ok: true,
|
||||
message:
|
||||
`${bericht.gelesen} Einträge gelesen, ${bericht.bestandGesetzt}× „hab ich“ gesetzt, ` +
|
||||
`${bericht.sichtungenAngelegt} Sichtungen übernommen`,
|
||||
bericht,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateMediaServerAction(
|
||||
connectionId: string,
|
||||
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
|
||||
) {
|
||||
return run(async (userId) => {
|
||||
await updateConnection(userId, connectionId, data);
|
||||
return { message: "Gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMediaServerAction(connectionId: string) {
|
||||
return run(async (userId) => {
|
||||
await deleteConnection(userId, connectionId);
|
||||
return { message: "Verbindung entfernt" };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import {
|
||||
addCollection,
|
||||
addTitle,
|
||||
createOrder,
|
||||
createUniverse,
|
||||
deleteOrder,
|
||||
removeTitle,
|
||||
renameOrder,
|
||||
renameUniverse,
|
||||
setDefaultOrder,
|
||||
setOrderPositions,
|
||||
shareUniverse,
|
||||
trashUniverse,
|
||||
unshareUniverse,
|
||||
UniverseError,
|
||||
} from "@/lib/universes";
|
||||
|
||||
/** Server-Aktionen für Universen. Zugriffsrechte prüft die Domänenschicht. */
|
||||
|
||||
export type UniverseActionResult = { ok: boolean; message?: string; id?: string };
|
||||
|
||||
async function run(
|
||||
fn: (userId: string) => Promise<{ message?: string; id?: string } | void>,
|
||||
): Promise<UniverseActionResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const result = (await fn(user.id)) ?? {};
|
||||
revalidatePath("/universen", "layout");
|
||||
revalidatePath("/", "layout");
|
||||
return { ok: true, message: result.message, id: result.id };
|
||||
} catch (error) {
|
||||
if (error instanceof UniverseError) return { ok: false, message: error.message };
|
||||
console.error("Universums-Aktion fehlgeschlagen:", error);
|
||||
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
|
||||
}
|
||||
}
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
const name = z.string().trim().min(1).max(120);
|
||||
|
||||
export async function createUniverseAction(rawName: string, description?: string) {
|
||||
return run(async (userId) => {
|
||||
const universe = await createUniverse(userId, name.parse(rawName), description);
|
||||
return { message: `„${universe.name}" angelegt`, id: universe.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameUniverseAction(
|
||||
universeId: string,
|
||||
data: { name?: string; description?: string | null },
|
||||
) {
|
||||
return run(async (userId) => {
|
||||
await renameUniverse(userId, id.parse(universeId), {
|
||||
...(data.name !== undefined ? { name: name.parse(data.name) } : {}),
|
||||
...(data.description !== undefined
|
||||
? { description: z.string().max(2000).nullable().parse(data.description) }
|
||||
: {}),
|
||||
});
|
||||
return { message: "Gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function trashUniverseAction(universeId: string) {
|
||||
return run(async (userId) => {
|
||||
await trashUniverse(userId, id.parse(universeId));
|
||||
return { message: "Universum gelöscht" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function addTitleToUniverseAction(universeId: string, titleId: string) {
|
||||
return run(async (userId) => {
|
||||
await addTitle(userId, id.parse(universeId), id.parse(titleId));
|
||||
return { message: "Aufgenommen" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFromUniverseAction(universeId: string, itemId: string) {
|
||||
return run(async (userId) => {
|
||||
await removeTitle(userId, id.parse(universeId), id.parse(itemId));
|
||||
return { message: "Aus dem Universum entfernt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function addCollectionAction(universeId: string, tmdbCollectionId: number) {
|
||||
return run(async (userId) => {
|
||||
const added = await addCollection(
|
||||
userId,
|
||||
id.parse(universeId),
|
||||
z.number().int().parse(tmdbCollectionId),
|
||||
);
|
||||
return {
|
||||
message:
|
||||
added === 0
|
||||
? "Alles daraus war schon drin"
|
||||
: `${added} ${added === 1 ? "Titel" : "Titel"} übernommen`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOrderAction(universeId: string, rawName: string) {
|
||||
return run(async (userId) => {
|
||||
const order = await createOrder(userId, id.parse(universeId), name.parse(rawName));
|
||||
return { message: "Reihenfolge angelegt", id: order.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameOrderAction(orderId: string, rawName: string) {
|
||||
return run(async (userId) => {
|
||||
await renameOrder(userId, id.parse(orderId), name.parse(rawName));
|
||||
return { message: "Umbenannt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOrderAction(orderId: string) {
|
||||
return run(async (userId) => {
|
||||
await deleteOrder(userId, id.parse(orderId));
|
||||
return { message: "Reihenfolge gelöscht" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function setDefaultOrderAction(orderId: string) {
|
||||
return run(async (userId) => {
|
||||
await setDefaultOrder(userId, id.parse(orderId));
|
||||
return { message: "Standardreihenfolge gesetzt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderAction(orderId: string, itemIds: string[]) {
|
||||
return run(async (userId) => {
|
||||
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
|
||||
return { message: "Reihenfolge gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function shareUniverseAction(
|
||||
universeId: string,
|
||||
email: string,
|
||||
permission: "READ" | "WRITE",
|
||||
) {
|
||||
return run(async (userId) => {
|
||||
const target = await shareUniverse(
|
||||
userId,
|
||||
id.parse(universeId),
|
||||
z.string().trim().email().parse(email),
|
||||
z.enum(["READ", "WRITE"]).parse(permission),
|
||||
);
|
||||
return { message: `Mit ${target.name} geteilt` };
|
||||
});
|
||||
}
|
||||
|
||||
export async function unshareUniverseAction(universeId: string, memberId: string) {
|
||||
return run(async (userId) => {
|
||||
await unshareUniverse(userId, id.parse(universeId), id.parse(memberId));
|
||||
return { message: "Freigabe aufgehoben" };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createSession } from "@/lib/auth";
|
||||
import { finishLogin, PasskeyError } from "@/lib/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const schema = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
rawId: z.string().min(1),
|
||||
type: z.literal("public-key"),
|
||||
clientExtensionResults: z.looseObject({}),
|
||||
response: z.looseObject({}),
|
||||
});
|
||||
|
||||
|
||||
/** Prüft die Antwort und legt bei Erfolg die Sitzung an. */
|
||||
export async function POST(request: Request) {
|
||||
const body = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
return NextResponse.json({ error: "Die Anfrage war unvollständig." }, { status: 422 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Die äußere Form ist geprüft; alles Weitere prüft die Bibliothek.
|
||||
const { userId } = await finishLogin(body.data as unknown as Parameters<typeof finishLogin>[0]);
|
||||
// Dieselbe Sitzung wie nach einer Passwortanmeldung — gleiche Tabelle,
|
||||
// gleiches Cookie.
|
||||
await createSession(userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof PasskeyError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Passkey-Anmeldung fehlgeschlagen:", error);
|
||||
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PasskeyError, startLogin } from "@/lib/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Optionen für die Anmeldung — bewusst ohne Anmeldung erreichbar und ohne
|
||||
* Angabe, welche Passkeys es gibt. Der Browser sucht selbst einen passenden.
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
return NextResponse.json(await startLogin());
|
||||
} catch (error) {
|
||||
if (error instanceof PasskeyError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Passkey-Anmeldung konnte nicht beginnen:", error);
|
||||
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { finishRegistration, PasskeyError } from "@/lib/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const antwortSchema = z.looseObject({
|
||||
id: z.string().min(1),
|
||||
rawId: z.string().min(1),
|
||||
type: z.literal("public-key"),
|
||||
clientExtensionResults: z.looseObject({}),
|
||||
response: z.looseObject({}),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().trim().min(1).max(60),
|
||||
response: antwortSchema,
|
||||
});
|
||||
|
||||
/** Prüft die Antwort des Authenticators und legt den Passkey an. */
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
|
||||
const body = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
return NextResponse.json({ error: "Die Anfrage war unvollständig." }, { status: 422 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Die äußere Form ist geprüft; die Feinheiten der eingebetteten
|
||||
// Base64-Felder prüft die Bibliothek beim Verifizieren.
|
||||
const passkey = await finishRegistration(
|
||||
user.id,
|
||||
body.data.response as unknown as Parameters<typeof finishRegistration>[1],
|
||||
body.data.name,
|
||||
);
|
||||
return NextResponse.json({ ok: true, passkey });
|
||||
} catch (error) {
|
||||
if (error instanceof PasskeyError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Passkey konnte nicht angelegt werden:", error);
|
||||
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { PasskeyError, startRegistration } from "@/lib/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Optionen zum Anlegen eines Passkeys — nur für angemeldete Nutzer. */
|
||||
export async function POST() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
|
||||
try {
|
||||
return NextResponse.json(await startRegistration(user.id));
|
||||
} catch (error) {
|
||||
if (error instanceof PasskeyError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Passkey-Registrierung konnte nicht beginnen:", error);
|
||||
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCachedImage, isValidImagePath, isValidSize } from "@/lib/images";
|
||||
|
||||
/**
|
||||
* Ausliefern zwischengespeicherter TMDB-Bilder.
|
||||
*
|
||||
* /api/image?size=w342&path=/abc123.jpg
|
||||
*
|
||||
* Größe und Pfad werden streng geprüft — der Endpunkt soll kein offener Proxy
|
||||
* für beliebige Adressen werden.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const size = url.searchParams.get("size") ?? "w342";
|
||||
const path = url.searchParams.get("path") ?? "";
|
||||
|
||||
if (!isValidSize(size) || !isValidImagePath(path)) {
|
||||
return NextResponse.json({ error: "Ungültige Bildanfrage." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const image = await getCachedImage(size, path);
|
||||
return new NextResponse(new Uint8Array(image.data), {
|
||||
headers: {
|
||||
"content-type": image.contentType,
|
||||
// Bilder unter einem TMDB-Pfad ändern sich nicht mehr.
|
||||
"cache-control": "public, max-age=31536000, immutable",
|
||||
"x-flimmer-cache": image.fromCache ? "hit" : "miss",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Bild nicht verfügbar." }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import { deletePasskey, PasskeyError, renamePasskey } from "@/lib/passkeys";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ id: string }> };
|
||||
|
||||
async function withUser(context: Context, fn: (userId: string, id: string) => Promise<unknown>) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
|
||||
const { id } = await context.params;
|
||||
if (!id) return NextResponse.json({ error: "Es fehlt die Kennung." }, { status: 400 });
|
||||
|
||||
try {
|
||||
await fn(user.id, id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof PasskeyError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
console.error("Passkey-Verwaltung fehlgeschlagen:", error);
|
||||
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Entfernt einen Passkey — nie den letzten Anmeldeweg. */
|
||||
export async function DELETE(_request: Request, context: Context) {
|
||||
return withUser(context, (userId, id) => deletePasskey(userId, id));
|
||||
}
|
||||
|
||||
const umbenennen = z.object({ name: z.string().trim().min(1).max(60) });
|
||||
|
||||
/** Benennt einen Passkey um. */
|
||||
export async function PATCH(request: Request, context: Context) {
|
||||
const body = umbenennen.safeParse(await request.json().catch(() => null));
|
||||
if (!body.success) {
|
||||
return NextResponse.json({ error: "Es fehlt ein Name." }, { status: 422 });
|
||||
}
|
||||
return withUser(context, (userId, id) => renamePasskey(userId, id, body.data.name));
|
||||
}
|
||||
@@ -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,104 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, readJson, withApi } from "@/lib/api";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
markEpisodesWatched,
|
||||
markMovieWatched,
|
||||
markSeasonWatched,
|
||||
markUpTo,
|
||||
undoWatches,
|
||||
unmarkEpisodes,
|
||||
unmarkMovie,
|
||||
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) }),
|
||||
z.object({ action: z.literal("movie") }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* DELETE — Sichtungen zurücknehmen.
|
||||
*
|
||||
* Drei Wege: gezielt über `watchIds` (die Antwort eines POST), über
|
||||
* `episodeIds`, oder mit `{"action":"movie"}` als Gegenstück zum
|
||||
* gleichnamigen POST — dafür braucht man die `watchIds` nicht zu kennen.
|
||||
* Zurückgenommen wird immer nur der laufende Durchlauf.
|
||||
*/
|
||||
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 if ("episodeIds" in body) {
|
||||
await unmarkEpisodes(user.id, entryId, body.episodeIds);
|
||||
} else {
|
||||
await unmarkMovie(user.id, entryId);
|
||||
}
|
||||
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,365 @@
|
||||
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",
|
||||
description:
|
||||
"Gezielt über watchIds, über episodeIds, oder mit action=movie als Gegenstück " +
|
||||
"zum gleichnamigen POST — dafür braucht man die watchIds nicht zu kennen. " +
|
||||
"Zurückgenommen wird immer nur der laufende Durchlauf; frühere Durchläufe " +
|
||||
"bleiben für die Statistik erhalten.",
|
||||
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"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { action: { const: "movie" } },
|
||||
required: ["action"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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",
|
||||
description:
|
||||
"Ohne 'reihenfolge' gilt die Standardreihenfolge des Universums — sie trägt in " +
|
||||
"'orders' das Kennzeichen 'isDefault'.",
|
||||
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" } },
|
||||
},
|
||||
patch: {
|
||||
summary: "Standardreihenfolge festlegen",
|
||||
description:
|
||||
"Bestimmt, welche Reihenfolge beim Öffnen erscheint. Braucht Schreibrecht; je " +
|
||||
"Universum trägt immer genau eine Reihenfolge den Standard.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
defaultOrderId: {
|
||||
type: "string",
|
||||
description: "Muss eine Reihenfolge dieses Universums sein.",
|
||||
},
|
||||
},
|
||||
required: ["defaultOrderId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": { description: "Gesetzt" },
|
||||
"404": { description: "Die Reihenfolge gehört nicht zu diesem Universum" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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,50 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, readJson, withApi } from "@/lib/api";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { addTitle, getUniverse, setDefaultOrder } 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 };
|
||||
});
|
||||
|
||||
const patchSchema = z.object({ defaultOrderId: z.string().min(1) });
|
||||
|
||||
/** PATCH /api/v1/universen/{id} — festlegen, welche Reihenfolge beim Öffnen erscheint. */
|
||||
export const PATCH = 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, patchSchema);
|
||||
|
||||
// Die Reihenfolge muss zu genau diesem Universum gehören. Ohne diese Prüfung
|
||||
// setzte ein PATCH auf Universum A den Standard in Universum B — die
|
||||
// Domänenschicht leitet das Universum ja aus der Reihenfolge ab.
|
||||
const order = await prisma.universeOrder.findFirst({
|
||||
where: { id: body.defaultOrderId, universeId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) return apiError("Diese Reihenfolge gehört nicht zu diesem Universum.", 404);
|
||||
|
||||
await setDefaultOrder(user.id, order.id);
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
import { readJson, withApi } from "@/lib/api";
|
||||
import { createUniverse, listUniverses } from "@/lib/universes";
|
||||
|
||||
/** GET /api/v1/universen — eigene und geteilte Universen. */
|
||||
export const GET = withApi(async (_request, user) => {
|
||||
const universes = await listUniverses(user.id);
|
||||
return { total: universes.length, items: universes };
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
/** POST /api/v1/universen — neues Universum anlegen. */
|
||||
export const POST = withApi(async (request, user) => {
|
||||
const body = await readJson(request, createSchema);
|
||||
const universe = await createUniverse(user.id, body.name, body.description);
|
||||
return { id: universe.id, name: universe.name, slug: universe.slug };
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type LibrarySort } from "@/lib/queries";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { LibraryCard } from "@/components/library/LibraryCard";
|
||||
import { LibraryFilters } from "@/components/library/LibraryFilters";
|
||||
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
|
||||
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
|
||||
|
||||
export const metadata: Metadata = { title: "Bibliothek" };
|
||||
|
||||
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"];
|
||||
const SORTS = ["aktualisiert", "hinzugefuegt", "titel", "bewertung", "jahr"];
|
||||
|
||||
export default async function LibraryPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
}) {
|
||||
const user = await requireUser("/bibliothek");
|
||||
const settings = await getSettings();
|
||||
const params = await searchParams;
|
||||
|
||||
const filters: Filters = {
|
||||
query: params.q?.trim() || undefined,
|
||||
status: STATUSES.includes(params.status ?? "") ? (params.status as EntryStatus) : undefined,
|
||||
type: params.typ === "SERIES" || params.typ === "MOVIE" ? (params.typ as TitleType) : undefined,
|
||||
genre: params.genre || undefined,
|
||||
tag: params.tag || undefined,
|
||||
provider: params.dienst || undefined,
|
||||
owned: params.sammlung === "1" ? true : undefined,
|
||||
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
|
||||
};
|
||||
|
||||
const [items, facets] = await Promise.all([
|
||||
getLibrary(user.id, filters),
|
||||
getLibraryFacets(user.id),
|
||||
]);
|
||||
|
||||
const hasFilters = Object.values(filters).some(Boolean);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Bibliothek</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
{facets.counts.ALLE === 0
|
||||
? "Noch nichts aufgenommen."
|
||||
: `${facets.counts.ALLE} ${facets.counts.ALLE === 1 ? "Titel" : "Titel"} — davon ${facets.counts.WATCHING} gerade am Laufen.`}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary">
|
||||
Titel hinzufügen
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<SearchPendingProvider>
|
||||
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
||||
|
||||
<SearchResults>
|
||||
<div className="mt-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="text-[var(--fl-muted)]">
|
||||
{facets.counts.ALLE === 0
|
||||
? "Deine Bibliothek ist noch leer."
|
||||
: hasFilters
|
||||
? "Zu dieser Auswahl passt nichts."
|
||||
: "Hier ist nichts."}
|
||||
</p>
|
||||
{facets.counts.ALLE === 0 && (
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary mt-4">
|
||||
Ersten Titel suchen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-[var(--fl-faint)]">
|
||||
{items.length} {items.length === 1 ? "Titel" : "Titel"}
|
||||
</p>
|
||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{items.map((item) => (
|
||||
<li key={item.entryId}>
|
||||
<LibraryCard item={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SearchResults>
|
||||
</SearchPendingProvider>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -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,101 @@
|
||||
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 { PasskeyList } from "@/components/settings/Passkeys";
|
||||
import { listPasskeys } from "@/lib/passkeys";
|
||||
import { MediaServerSettings } from "@/components/settings/MediaServer";
|
||||
import { listConnections } from "@/lib/mediaserver";
|
||||
import { env } from "@/lib/env";
|
||||
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;
|
||||
|
||||
const passkeys = env.passkeys.enabled ? await listPasskeys(session.id) : [];
|
||||
const medienserver = await listConnections(session.id);
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{env.passkeys.enabled && (
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-1 text-lg font-semibold">Passkeys</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Anmelden ohne Passwort — per Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
|
||||
</p>
|
||||
<PasskeyList
|
||||
passkeys={passkeys.map((passkey) => ({
|
||||
...passkey,
|
||||
createdAt: passkey.createdAt.toISOString(),
|
||||
lastUsedAt: passkey.lastUsedAt?.toISOString() ?? null,
|
||||
}))}
|
||||
available={env.passkeys.isAvailable}
|
||||
hinweis={
|
||||
env.passkeys.isAvailable
|
||||
? null
|
||||
: "Passkeys brauchen eine verschlüsselte Verbindung. Diese Instanz läuft über " +
|
||||
"einfaches HTTP — richte HTTPS ein, dann erscheint hier die Verwaltung."
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-1 text-lg font-semibold">Medienserver</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Bestand und Sichtungen aus Jellyfin übernehmen. Der Abgleich ergänzt nur — er löscht
|
||||
nie etwas, das in Flimmer steht.
|
||||
</p>
|
||||
<MediaServerSettings
|
||||
verbindungen={medienserver.map((verbindung) => ({
|
||||
id: verbindung.id,
|
||||
name: verbindung.name,
|
||||
baseUrl: verbindung.baseUrl,
|
||||
serverUserName: verbindung.serverUserName,
|
||||
enabled: verbindung.enabled,
|
||||
syncOwned: verbindung.syncOwned,
|
||||
syncWatched: verbindung.syncWatched,
|
||||
lastSyncAt: verbindung.lastSyncAt?.toISOString() ?? null,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -332,6 +332,18 @@
|
||||
animation: fl-glimmer 900ms var(--ease-glow);
|
||||
}
|
||||
|
||||
/* Rückmeldungen fahren von unten herein — kurz und ohne Sprung. */
|
||||
@keyframes fl-toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getUpcomingEpisodes } from "@/lib/stats";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CalendarMonth } from "@/components/CalendarMonth";
|
||||
import { episodeCode, formatDateLong } from "@/lib/labels";
|
||||
|
||||
export const metadata: Metadata = { title: "Kalender" };
|
||||
|
||||
/** Monatsangabe aus der URL („2026-09") oder der laufende Monat. */
|
||||
function parseMonth(value: string | undefined): Date {
|
||||
const match = value?.match(/^(\d{4})-(\d{2})$/);
|
||||
if (match) {
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]) - 1;
|
||||
if (year >= 1900 && year <= 2200 && month >= 0 && month <= 11) {
|
||||
return new Date(year, month, 1);
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
}
|
||||
|
||||
function monthParam(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function CalendarPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ monat?: string }>;
|
||||
}) {
|
||||
const user = await requireUser("/kalender");
|
||||
const settings = await getSettings();
|
||||
const params = await searchParams;
|
||||
|
||||
const month = parseMonth(params.monat);
|
||||
const from = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
// Das Raster reicht in den Vor- und Folgemonat hinein — die Termine auch.
|
||||
const gridFrom = new Date(from.getTime() - 7 * 86_400_000);
|
||||
const gridTo = new Date(from.getFullYear(), from.getMonth() + 1, 7);
|
||||
|
||||
const episodes = await getUpcomingEpisodes(user.id, gridFrom, gridTo);
|
||||
const inMonth = episodes.filter((episode) => episode.airDate.getMonth() === month.getMonth());
|
||||
|
||||
const previous = new Date(month.getFullYear(), month.getMonth() - 1, 1);
|
||||
const next = new Date(month.getFullYear(), month.getMonth() + 1, 1);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Kalender</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Ausstrahlungstermine der Serien in deiner Bibliothek.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-2" aria-label="Monat wechseln">
|
||||
<Link
|
||||
href={`/kalender?monat=${monthParam(previous)}`}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
aria-label={`Zum ${previous.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
|
||||
>
|
||||
←
|
||||
</Link>
|
||||
<span className="min-w-40 text-center font-medium">
|
||||
{month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
<Link
|
||||
href={`/kalender?monat=${monthParam(next)}`}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
aria-label={`Zum ${next.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
|
||||
>
|
||||
→
|
||||
</Link>
|
||||
<Link href="/kalender" className="ml-1 text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
|
||||
Heute
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="fl-card overflow-x-auto p-3 sm:p-4">
|
||||
<div className="min-w-[44rem]">
|
||||
<CalendarMonth month={month} episodes={episodes} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-6" aria-labelledby="liste">
|
||||
<h2 id="liste" className="mb-3 text-lg font-semibold">
|
||||
Termine im {month.toLocaleDateString("de-DE", { month: "long" })}
|
||||
</h2>
|
||||
|
||||
{inMonth.length === 0 ? (
|
||||
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||
In diesem Monat läuft nichts aus deiner Bibliothek.
|
||||
</div>
|
||||
) : (
|
||||
<ol className="fl-card divide-y divide-[var(--fl-border)]">
|
||||
{inMonth.map((episode) => (
|
||||
<li key={episode.episodeId} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
|
||||
<span className="w-52 shrink-0 text-sm text-[var(--fl-faint)]">
|
||||
{formatDateLong(episode.airDate)}
|
||||
</span>
|
||||
<Link
|
||||
href={`/titel/${episode.titleId}`}
|
||||
className="min-w-0 flex-1 text-sm hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
<span className="font-medium">{episode.title}</span>
|
||||
<span className="ml-2 font-mono text-xs text-[var(--fl-faint)]">
|
||||
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
|
||||
</span>
|
||||
<span className="ml-2 text-[var(--fl-muted)]">{episode.name}</span>
|
||||
</Link>
|
||||
{episode.runtime && (
|
||||
<span className="shrink-0 text-xs text-[var(--fl-faint)]">{episode.runtime} min</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +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 = {
|
||||
@@ -73,7 +86,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
{children}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -2,21 +2,196 @@ import Link from "next/link";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { env } from "@/lib/env";
|
||||
import {
|
||||
getContinueWatching,
|
||||
getNewlyAvailable,
|
||||
getUpcomingEpisodes,
|
||||
} from "@/lib/stats";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { episodeCode, formatDate, formatRelative, isUpcoming } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { QuickWatch } from "@/components/library/QuickWatch";
|
||||
|
||||
/**
|
||||
* Übersichtsseite.
|
||||
* Übersicht: der Einstieg für „was schaue ich als Nächstes".
|
||||
*
|
||||
* In dieser Etappe steht hier die Begrüßung und der Hinweis auf die nächsten
|
||||
* Schritte; ab Etappe 5 ziehen „Weiterschauen", Kalender und Neuzugänge ein.
|
||||
* Drei Fragen, drei Bereiche — weiterschauen, was demnächst kommt, was auf der
|
||||
* Watchlist verfügbar geworden ist.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireUser();
|
||||
const settings = await getSettings();
|
||||
|
||||
const now = new Date();
|
||||
const inFourWeeks = new Date(now.getTime() + 28 * 86_400_000);
|
||||
|
||||
const [continueItems, upcoming, available, libraryCount] = await Promise.all([
|
||||
getContinueWatching(user.id, 8),
|
||||
getUpcomingEpisodes(user.id, now, inFourWeeks),
|
||||
getNewlyAvailable(user.id, user.region),
|
||||
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||
]);
|
||||
|
||||
if (libraryCount === 0) {
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<EmptyStart tmdbReady={env.tmdb.isConfigured} name={user.name} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<h1 className="sr-only">Übersicht</h1>
|
||||
|
||||
<section aria-labelledby="weiterschauen">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h2 id="weiterschauen" className="text-lg font-semibold">
|
||||
Weiterschauen
|
||||
</h2>
|
||||
<Link href="/bibliothek?status=WATCHING" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
|
||||
Alle laufenden
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{continueItems.length === 0 ? (
|
||||
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||
Gerade läuft nichts. Setz eine Serie auf „Schaue gerade", dann steht sie hier.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{continueItems.map((item) => (
|
||||
<li key={item.entryId} className="fl-card flex gap-3 p-3">
|
||||
<Link href={`/titel/${item.titleId}`} className="w-16 shrink-0 sm:w-20">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w185" />
|
||||
</Link>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Link href={`/titel/${item.titleId}`} className="truncate font-medium hover:text-[var(--fl-accent)]">
|
||||
{item.title}
|
||||
</Link>
|
||||
|
||||
{item.next && (
|
||||
<p className="mt-0.5 truncate text-sm">
|
||||
<span className="fl-gradient-text font-mono font-semibold">
|
||||
{episodeCode(item.next.seasonNumber, item.next.episodeNumber)}
|
||||
</span>{" "}
|
||||
<span className="text-[var(--fl-muted)]">{item.next.name}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
|
||||
{item.watched} von {item.total} Episoden
|
||||
{item.lastWatchedAt ? ` · zuletzt ${formatRelative(item.lastWatchedAt)}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
percent={item.percent}
|
||||
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5">
|
||||
{item.next && isUpcoming(item.next.airDate) ? (
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Nächste Episode erst am {formatDate(item.next.airDate)}
|
||||
</p>
|
||||
) : (
|
||||
<QuickWatch entryId={item.entryId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<section aria-labelledby="demnaechst">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h2 id="demnaechst" className="text-lg font-semibold">
|
||||
Demnächst
|
||||
</h2>
|
||||
<Link href="/kalender" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
|
||||
Zum Kalender
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="fl-card p-4">
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
In den nächsten vier Wochen ist keine Episode deiner Serien angekündigt.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-2.5">
|
||||
{upcoming.slice(0, 6).map((episode) => (
|
||||
<li key={episode.episodeId} className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs text-[var(--fl-faint)]">
|
||||
{formatDate(episode.airDate)}
|
||||
</span>
|
||||
<Link
|
||||
href={`/titel/${episode.titleId}`}
|
||||
className="min-w-0 flex-1 truncate text-sm hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
<span className="font-medium">{episode.title}</span>
|
||||
<span className="ml-1.5 font-mono text-xs text-[var(--fl-faint)]">
|
||||
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="verfuegbar">
|
||||
<h2 id="verfuegbar" className="mb-3 text-lg font-semibold">
|
||||
Neu verfügbar
|
||||
</h2>
|
||||
|
||||
<div className="fl-card p-4">
|
||||
{available.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
Auf deiner Watchlist ist zuletzt nichts neu dazugekommen. Sobald ein Titel auf
|
||||
einem Dienst in {user.region} auftaucht, steht er hier.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{available.slice(0, 5).map((item) => (
|
||||
<li key={item.titleId} className="flex items-center gap-3">
|
||||
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/titel/${item.titleId}`}
|
||||
className="block truncate text-sm font-medium hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||
{item.providers.map((provider) => provider.name).join(", ")} ·{" "}
|
||||
{formatRelative(item.since)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyStart({ name, tmdbReady }: { name: string; tmdbReady: boolean }) {
|
||||
return (
|
||||
<section className="fl-card relative overflow-hidden p-6 sm:p-10">
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -24,60 +199,31 @@ export default async function DashboardPage() {
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<FlimmerMark className="h-12 w-12" />
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
Hallo {user.name.split(" ")[0]}.
|
||||
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
Hallo {name.split(" ")[0]}.
|
||||
</h1>
|
||||
<p className="mt-2 max-w-xl text-[var(--fl-muted)]">
|
||||
Schön, dass du da bist. Sobald du deinen ersten Titel hinzufügst, findest du hier
|
||||
deine laufenden Serien, die nächsten Episoden und was auf deiner Watchlist gerade
|
||||
verfügbar geworden ist.
|
||||
Deine Bibliothek ist noch leer. Such dir eine Serie oder einen Film — danach findest du
|
||||
hier, was als Nächstes dran ist, welche Episoden demnächst kommen und was auf deiner
|
||||
Watchlist verfügbar geworden ist.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary">
|
||||
Titel suchen und hinzufügen
|
||||
Titel suchen
|
||||
</Link>
|
||||
<Link href="/bibliothek" className="fl-btn fl-btn-ghost">
|
||||
Zur Bibliothek
|
||||
<Link href="/titel/neu" className="fl-btn fl-btn-ghost">
|
||||
Titel selbst anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!env.tmdb.isConfigured && (
|
||||
{!tmdbReady && (
|
||||
<p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
|
||||
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine
|
||||
Metadaten — trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte
|
||||
die Instanz neu.
|
||||
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine Metadaten —
|
||||
trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte die Instanz neu.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<PlaceholderCard
|
||||
title="Weiterschauen"
|
||||
text="Deine laufenden Serien mit der jeweils nächsten Episode."
|
||||
/>
|
||||
<PlaceholderCard
|
||||
title="Demnächst"
|
||||
text="Kalender der Episoden, die in den nächsten Wochen ausgestrahlt werden."
|
||||
/>
|
||||
<PlaceholderCard
|
||||
title="Neu verfügbar"
|
||||
text="Watchlist-Titel, die auf einem deiner Dienste aufgetaucht sind."
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderCard({ title, text }: { title: string; text: string }) {
|
||||
return (
|
||||
<div className="fl-card p-5">
|
||||
<h2 className="text-sm font-semibold tracking-wide text-[var(--fl-muted)] uppercase">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[var(--fl-faint)]">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getTrash } from "@/lib/queries";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { TrashActions } from "@/components/library/TrashActions";
|
||||
import { formatRelative, TYPE_LABEL } from "@/lib/labels";
|
||||
|
||||
export const metadata: Metadata = { title: "Papierkorb" };
|
||||
|
||||
/** Nach dieser Frist räumt der Hintergrundlauf endgültig auf. */
|
||||
const RETENTION_DAYS = 30;
|
||||
|
||||
export default async function TrashPage() {
|
||||
const user = await requireUser("/papierkorb");
|
||||
const settings = await getSettings();
|
||||
const entries = await getTrash(user.id);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Papierkorb</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Entfernte Titel bleiben {RETENTION_DAYS} Tage liegen. Danach werden sie samt ihrer
|
||||
Sichtungen endgültig gelöscht.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="text-[var(--fl-muted)]">Der Papierkorb ist leer.</p>
|
||||
<Link href="/bibliothek" className="fl-btn fl-btn-ghost mt-4">
|
||||
Zur Bibliothek
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{entries.map((entry) => {
|
||||
const deletedAt = entry.deletedAt!;
|
||||
const purgeAt = new Date(deletedAt.getTime() + RETENTION_DAYS * 86_400_000);
|
||||
|
||||
return (
|
||||
<li key={entry.id} className="fl-card flex flex-wrap items-center gap-4 p-3 sm:p-4">
|
||||
<div className="w-16 shrink-0">
|
||||
<Poster path={entry.title.posterPath} alt={entry.title.title} size="w154" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{entry.title.title}</p>
|
||||
<p className="text-sm text-[var(--fl-faint)]">
|
||||
{TYPE_LABEL[entry.title.type]}
|
||||
{entry.title.releaseDate ? ` · ${entry.title.releaseDate.getFullYear()}` : ""}
|
||||
{" · entfernt "}
|
||||
{formatRelative(deletedAt)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--fl-warning)]">
|
||||
Wird {formatRelative(purgeAt)} endgültig gelöscht
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TrashActions entryId={entry.id} title={entry.title.title} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import {
|
||||
getActiveYears,
|
||||
getGenreDistribution,
|
||||
getMonthlyActivity,
|
||||
getMostWatched,
|
||||
getProviderDistribution,
|
||||
getTopRated,
|
||||
getTotals,
|
||||
getYearReview,
|
||||
} from "@/lib/stats";
|
||||
import { formatMinutes } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { DistributionBars, MonthlyChart } from "@/components/stats/Charts";
|
||||
|
||||
export const metadata: Metadata = { title: "Statistik" };
|
||||
|
||||
export default async function StatsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ jahr?: string }>;
|
||||
}) {
|
||||
const user = await requireUser("/statistik");
|
||||
const settings = await getSettings();
|
||||
const params = await searchParams;
|
||||
|
||||
const years = await getActiveYears(user.id);
|
||||
const requested = Number.parseInt(params.jahr ?? "", 10);
|
||||
const year = years.includes(requested) ? requested : (years[0] ?? new Date().getFullYear());
|
||||
|
||||
const [totals, monthly, providers, genres, topRated, mostWatched, review] = await Promise.all([
|
||||
getTotals(user.id),
|
||||
getMonthlyActivity(user.id, 12),
|
||||
getProviderDistribution(user.id),
|
||||
getGenreDistribution(user.id),
|
||||
getTopRated(user.id, 5),
|
||||
getMostWatched(user.id, 5),
|
||||
getYearReview(user.id, year),
|
||||
]);
|
||||
|
||||
if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) {
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
|
||||
</header>
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
|
||||
Noch keine Sichtungen. Sobald du die erste Episode abhakst, entstehen hier
|
||||
Schauzeit, Verteilungen und der Jahresrückblick.
|
||||
</p>
|
||||
<Link href="/bibliothek" className="fl-btn fl-btn-primary mt-4">
|
||||
Zur Bibliothek
|
||||
</Link>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const days = totals.minutes / (60 * 24);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Alles gerechnet aus deinen Sichtungen — Rewatches zählen mit.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="fl-card mb-6 p-5 sm:p-7" aria-label="Gesamtschauzeit">
|
||||
<p className="text-sm font-medium text-[var(--fl-muted)]">Gesamte Schauzeit</p>
|
||||
<p className="fl-gradient-text mt-1 text-5xl font-semibold tabular-nums sm:text-6xl">
|
||||
{Math.round(totals.minutes / 60).toLocaleString("de-DE")} h
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
{formatMinutes(totals.minutes)} · das sind {days.toFixed(1).replace(".", ",")} Tage am Stück
|
||||
</p>
|
||||
|
||||
<dl className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<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")} />
|
||||
<Stat
|
||||
label="In der Bibliothek"
|
||||
value={`${totals.seriesCount} Serien · ${totals.movieCount} Filme`}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="fl-card mb-6 p-5" aria-labelledby="verlauf">
|
||||
<h2 id="verlauf" className="mb-1 text-lg font-semibold">
|
||||
Die letzten zwölf Monate
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Abgehakte Episoden und Filme je Monat.
|
||||
</p>
|
||||
<MonthlyChart points={monthly.map(({ label, episodes, movies, minutes }) => ({ label, episodes, movies, minutes }))} />
|
||||
</section>
|
||||
|
||||
<div className="mb-6 grid gap-6 lg:grid-cols-2">
|
||||
<section className="fl-card p-5" aria-labelledby="dienste">
|
||||
<h2 id="dienste" className="mb-1 text-lg font-semibold">
|
||||
Nach Streamingdienst
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Wo du tatsächlich geschaut hast — aus dem Feld „wo schaue ich das".
|
||||
</p>
|
||||
<DistributionBars
|
||||
rows={providers.slice(0, 8)}
|
||||
emptyText="Noch keine Sichtung einem Dienst zugeordnet."
|
||||
useBrandColors
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5" aria-labelledby="genres">
|
||||
<h2 id="genres" className="mb-1 text-lg font-semibold">
|
||||
Nach Genre
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Titel mit mehreren Genres zählen in jedem mit.
|
||||
</p>
|
||||
<DistributionBars rows={genres.slice(0, 8)} emptyText="Noch keine Genres erfasst." />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="fl-card mb-6 p-5" aria-labelledby="rueckblick">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 id="rueckblick" className="text-lg font-semibold">
|
||||
{review.year} in Zahlen
|
||||
</h2>
|
||||
{years.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Jahr wählen">
|
||||
{years.slice(0, 6).map((entry) => (
|
||||
<Link
|
||||
key={entry}
|
||||
href={`/statistik?jahr=${entry}`}
|
||||
aria-current={entry === review.year ? "true" : undefined}
|
||||
className="rounded-full border px-3 py-1 text-sm font-medium transition-colors"
|
||||
style={
|
||||
entry === review.year
|
||||
? {
|
||||
backgroundImage: "var(--fl-gradient)",
|
||||
color: "var(--fl-on-accent)",
|
||||
borderColor: "transparent",
|
||||
}
|
||||
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
|
||||
}
|
||||
>
|
||||
{entry}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<Stat label="Schauzeit" value={formatMinutes(review.minutes)} />
|
||||
<Stat label="Episoden" value={review.episodes.toLocaleString("de-DE")} />
|
||||
<Stat label="Filme" value={review.movies.toLocaleString("de-DE")} />
|
||||
<Stat label="Neu angefangen" value={review.titlesStarted.toLocaleString("de-DE")} />
|
||||
<Stat label="Abgeschlossen" value={review.titlesCompleted.toLocaleString("de-DE")} />
|
||||
</dl>
|
||||
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
{review.busiestMonth && (
|
||||
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
|
||||
<span className="text-[var(--fl-faint)]">Stärkster Monat: </span>
|
||||
<span className="font-medium">{review.busiestMonth.label}</span>
|
||||
<span className="text-[var(--fl-muted)]">
|
||||
{" "}
|
||||
mit {formatMinutes(review.busiestMonth.minutes)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{review.topTitle && (
|
||||
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
|
||||
<span className="text-[var(--fl-faint)]">Die meiste Zeit ging an: </span>
|
||||
<Link href={`/titel/${review.topTitle.titleId}`} className="font-medium hover:text-[var(--fl-accent)]">
|
||||
{review.topTitle.title}
|
||||
</Link>
|
||||
<span className="text-[var(--fl-muted)]"> ({formatMinutes(review.topTitle.minutes)})</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TitleList
|
||||
id="meistzeit"
|
||||
heading="Wo die meiste Zeit hinging"
|
||||
items={mostWatched}
|
||||
renderMeta={(item) => formatMinutes(item.minutes)}
|
||||
emptyText="Noch nichts gesehen."
|
||||
/>
|
||||
<TitleList
|
||||
id="bestbewertet"
|
||||
heading="Deine besten Bewertungen"
|
||||
items={topRated}
|
||||
renderMeta={(item) => (item.rating ? `${item.rating}/10` : "—")}
|
||||
emptyText="Noch nichts bewertet."
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
id,
|
||||
heading,
|
||||
items,
|
||||
renderMeta,
|
||||
emptyText,
|
||||
}: {
|
||||
id: string;
|
||||
heading: string;
|
||||
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number; type: string }[];
|
||||
renderMeta: (item: { rating: number | null; minutes: number }) => string;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="fl-card p-5" aria-labelledby={id}>
|
||||
<h2 id={id} className="mb-4 text-lg font-semibold">
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">{emptyText}</p>
|
||||
) : (
|
||||
<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>
|
||||
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/titel/${item.titleId}`}
|
||||
className="min-w-0 flex-1 truncate text-sm font-medium hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
|
||||
{renderMeta(item)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { env } from "@/lib/env";
|
||||
import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "@/lib/tmdb";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { SearchField } from "@/components/SearchField";
|
||||
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
|
||||
|
||||
export const metadata: Metadata = { title: "Suchen" };
|
||||
|
||||
type Filter = "alle" | "serien" | "filme";
|
||||
|
||||
const FILTERS: { key: Filter; label: string }[] = [
|
||||
{ key: "alle", label: "Alles" },
|
||||
{ key: "serien", label: "Serien" },
|
||||
{ key: "filme", label: "Filme" },
|
||||
];
|
||||
|
||||
/** Normalisiert die drei TMDB-Antwortformen auf eine gemeinsame Darstellung. */
|
||||
function normalize(item: TmdbSearchResult, fallbackType: "tv" | "movie") {
|
||||
const type = item.media_type === "tv" || item.media_type === "movie" ? item.media_type : fallbackType;
|
||||
const date = type === "tv" ? item.first_air_date : item.release_date;
|
||||
return {
|
||||
tmdbId: item.id,
|
||||
type,
|
||||
title: (type === "tv" ? item.name : item.title) ?? item.original_name ?? item.original_title ?? "Ohne Titel",
|
||||
overview: item.overview ?? "",
|
||||
posterPath: item.poster_path ?? null,
|
||||
year: date ? date.slice(0, 4) : null,
|
||||
rating: item.vote_average ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SearchPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; typ?: string }>;
|
||||
}) {
|
||||
const user = await requireUser("/suche");
|
||||
const settings = await getSettings();
|
||||
|
||||
const params = await searchParams;
|
||||
const query = (params.q ?? "").trim();
|
||||
const filter: Filter = FILTERS.some((f) => f.key === params.typ) ? (params.typ as Filter) : "alle";
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Suchen</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Serien und Filme aus der TMDB-Datenbank — mit Postern, Staffeln und Episoden.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SearchPendingProvider>
|
||||
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
||||
|
||||
<p className="mt-3 text-sm text-[var(--fl-faint)]">
|
||||
Nichts dabei?{" "}
|
||||
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
|
||||
Titel selbst anlegen
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<SearchResults>
|
||||
<div className="mt-8">
|
||||
{!env.tmdb.isConfigured ? (
|
||||
<Notice tone="warning">
|
||||
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in
|
||||
der <code>.env</code> ein und starte die Instanz neu.
|
||||
</Notice>
|
||||
) : query.length < 2 ? (
|
||||
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
|
||||
) : (
|
||||
<Results query={query} filter={filter} />
|
||||
)}
|
||||
</div>
|
||||
</SearchResults>
|
||||
</SearchPendingProvider>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
async function Results({ query, filter }: { query: string; filter: Filter }) {
|
||||
let items: ReturnType<typeof normalize>[];
|
||||
|
||||
try {
|
||||
if (filter === "serien") {
|
||||
const data = await searchSeries(query);
|
||||
items = data.results.map((item) => normalize(item, "tv"));
|
||||
} else if (filter === "filme") {
|
||||
const data = await searchMovies(query);
|
||||
items = data.results.map((item) => normalize(item, "movie"));
|
||||
} else {
|
||||
const data = await searchMulti(query);
|
||||
items = data.results.map((item) => normalize(item, "movie"));
|
||||
}
|
||||
} catch (error) {
|
||||
return (
|
||||
<Notice tone="danger">
|
||||
{error instanceof Error ? error.message : "Die Suche bei TMDB ist fehlgeschlagen."}
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <Notice>Zu „{query}" hat TMDB nichts gefunden.</Notice>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-[var(--fl-faint)]">
|
||||
{items.length} {items.length === 1 ? "Treffer" : "Treffer"} zu „{query}"
|
||||
</p>
|
||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{items.map((item) => (
|
||||
<li key={`${item.type}-${item.tmdbId}`}>
|
||||
<Link
|
||||
href={`/tmdb/${item.type === "tv" ? "serie" : "film"}/${item.tmdbId}`}
|
||||
className="fl-poster-card group block focus:outline-none"
|
||||
>
|
||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{item.type === "tv" ? "Serie" : "Film"}
|
||||
{item.year ? ` · ${item.year}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Notice({
|
||||
children,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
}) {
|
||||
const color =
|
||||
tone === "warning" ? "var(--fl-warning)" : tone === "danger" ? "var(--fl-danger)" : "var(--fl-muted)";
|
||||
return (
|
||||
<p
|
||||
className="fl-card px-4 py-3 text-sm"
|
||||
style={{
|
||||
color,
|
||||
borderColor:
|
||||
tone === "neutral" ? undefined : `color-mix(in oklab, ${color} 45%, transparent)`,
|
||||
backgroundColor:
|
||||
tone === "neutral" ? undefined : `color-mix(in oklab, ${color} 10%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureTitle } from "@/lib/catalog";
|
||||
import { getNextEpisode, getProgress, getWatchedEpisodeIds } from "@/lib/library";
|
||||
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Backdrop, Poster } from "@/components/Poster";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import {
|
||||
ProviderList,
|
||||
type ProviderBadgeData,
|
||||
type ProviderKindKey,
|
||||
} from "@/components/ProviderBadges";
|
||||
import { SeasonList, type SeasonData } from "@/components/library/SeasonList";
|
||||
import {
|
||||
AddToLibrary,
|
||||
EntryActions,
|
||||
RatingPicker,
|
||||
StatusPicker,
|
||||
} from "@/components/library/EntryControls";
|
||||
import { EntryNotes } from "@/components/library/EntryNotes";
|
||||
import { NextEpisodeCard, WatchMovieButton } from "@/components/library/NextEpisode";
|
||||
|
||||
type Params = Promise<{ id: string }>;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const title = await prisma.title.findUnique({ where: { id }, select: { title: true } });
|
||||
return { title: title?.title ?? "Titel" };
|
||||
}
|
||||
|
||||
export default async function TitlePage({ params }: { params: Params }) {
|
||||
const user = await requireUser();
|
||||
const settings = await getSettings();
|
||||
const { id } = await params;
|
||||
|
||||
const title = await prisma.title.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
seasons: {
|
||||
orderBy: { seasonNumber: "asc" },
|
||||
include: { episodes: { orderBy: { episodeNumber: "asc" } } },
|
||||
},
|
||||
providers: { where: { region: user.region }, include: { provider: true } },
|
||||
entries: {
|
||||
where: { userId: user.id },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!title) notFound();
|
||||
|
||||
// Im Hintergrund auffrischen, falls der Eintrag alt ist. Schlägt das fehl,
|
||||
// bleibt der lokale Stand stehen — die Seite funktioniert trotzdem.
|
||||
if (title.tmdbId) {
|
||||
await ensureTitle(title.type, title.tmdbId, { region: user.region }).catch(() => undefined);
|
||||
}
|
||||
|
||||
const entry = title.entries[0] ?? null;
|
||||
const isSeries = title.type === "SERIES";
|
||||
|
||||
const [progress, next, watchedIds] = entry
|
||||
? await Promise.all([
|
||||
getProgress(user.id, entry.id),
|
||||
isSeries ? getNextEpisode(user.id, entry.id) : Promise.resolve(null),
|
||||
isSeries ? getWatchedEpisodeIds(user.id, entry.id) : Promise.resolve(new Set<string>()),
|
||||
])
|
||||
: [null, null, new Set<string>()];
|
||||
|
||||
const providerGroups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>> = {};
|
||||
for (const row of title.providers) {
|
||||
(providerGroups[row.kind as ProviderKindKey] ??= []).push({
|
||||
id: row.provider.id,
|
||||
name: row.provider.name,
|
||||
logoPath: row.provider.logoPath,
|
||||
color: row.provider.color,
|
||||
});
|
||||
}
|
||||
const suggestion = providerGroups.FLATRATE?.[0]?.name ?? providerGroups.FREE?.[0]?.name ?? null;
|
||||
|
||||
const seasons: SeasonData[] = title.seasons.map((season) => ({
|
||||
id: season.id,
|
||||
seasonNumber: season.seasonNumber,
|
||||
name: season.name,
|
||||
overview: season.overview,
|
||||
airDate: season.airDate?.toISOString() ?? null,
|
||||
episodes: season.episodes.map((episode) => ({
|
||||
id: episode.id,
|
||||
episodeNumber: episode.episodeNumber,
|
||||
name: episode.name,
|
||||
overview: episode.overview,
|
||||
airDate: episode.airDate?.toISOString() ?? null,
|
||||
runtime: episode.runtime,
|
||||
})),
|
||||
}));
|
||||
|
||||
const year = title.releaseDate?.getFullYear() ?? null;
|
||||
const movieWatched = !isSeries && (progress?.watched ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<article>
|
||||
<div className="fl-card relative overflow-hidden">
|
||||
<Backdrop path={title.backdropPath} alt="" />
|
||||
|
||||
<div className="relative flex flex-col gap-6 p-5 sm:flex-row sm:p-7">
|
||||
<div className="w-36 shrink-0 sm:w-48">
|
||||
<Poster path={title.posterPath} alt={title.title} size="w500" priority />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
{isSeries ? "Serie" : "Film"}
|
||||
{year ? ` · ${year}` : ""}
|
||||
{title.status ? ` · ${title.status}` : ""}
|
||||
</p>
|
||||
{entry?.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"
|
||||
style={{
|
||||
color: STATUS_COLOR[entry.status as StatusKey],
|
||||
backgroundColor: `color-mix(in oklab, ${STATUS_COLOR[entry.status as StatusKey]} 16%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[entry.status as StatusKey]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{title.title}
|
||||
</h1>
|
||||
{title.originalTitle && title.originalTitle !== title.title && (
|
||||
<p className="mt-0.5 text-sm text-[var(--fl-faint)]">{title.originalTitle}</p>
|
||||
)}
|
||||
{title.tagline && (
|
||||
<p className="mt-2 text-sm italic text-[var(--fl-muted)]">„{title.tagline}"</p>
|
||||
)}
|
||||
|
||||
<dl className="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
{isSeries && (
|
||||
<Fact label="Umfang">
|
||||
{title.seasonCount} {title.seasonCount === 1 ? "Staffel" : "Staffeln"} ·{" "}
|
||||
{title.episodeCount} Episoden
|
||||
</Fact>
|
||||
)}
|
||||
{title.runtime ? (
|
||||
<Fact label={isSeries ? "Pro Episode" : "Laufzeit"}>{title.runtime} min</Fact>
|
||||
) : null}
|
||||
{title.voteAverage ? <Fact label="TMDB">{title.voteAverage.toFixed(1)} / 10</Fact> : null}
|
||||
{entry && progress ? (
|
||||
<Fact label="Deine Zeit">{formatMinutes(progress.minutesWatched)}</Fact>
|
||||
) : null}
|
||||
</dl>
|
||||
|
||||
{title.genres.length > 0 && (
|
||||
<ul className="mt-4 flex flex-wrap gap-1.5">
|
||||
{title.genres.map((genre) => (
|
||||
<li
|
||||
key={genre}
|
||||
className="rounded-full border border-[var(--fl-border)] px-2.5 py-0.5 text-xs text-[var(--fl-muted)]"
|
||||
>
|
||||
{genre}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{title.overview && (
|
||||
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-[var(--fl-muted)]">
|
||||
{title.overview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
{entry ? (
|
||||
<div className="space-y-4">
|
||||
{progress && (
|
||||
<div className="max-w-md">
|
||||
<ProgressBar
|
||||
percent={progress.percent}
|
||||
label={
|
||||
isSeries
|
||||
? `${progress.watched} von ${progress.total} Episoden gesehen — noch ${formatMinutes(progress.minutesLeft)}`
|
||||
: movieWatched
|
||||
? "Gesehen"
|
||||
: `Noch ${formatMinutes(progress.minutesLeft)}`
|
||||
}
|
||||
showValue
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSeries && next && <NextEpisodeCard entryId={entry.id} next={{ ...next, airDate: next.airDate?.toISOString() ?? null }} />}
|
||||
{isSeries && !next && progress?.total ? (
|
||||
<p className="rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
|
||||
Durchgeschaut — alle {progress.total} Episoden abgehakt.
|
||||
</p>
|
||||
) : null}
|
||||
{!isSeries && <WatchMovieButton entryId={entry.id} watched={movieWatched} />}
|
||||
|
||||
<StatusPicker entryId={entry.id} status={entry.status as StatusKey} />
|
||||
</div>
|
||||
) : (
|
||||
<AddToLibrary titleId={title.id} isSeries={isSeries} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entry && (
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<section className="order-2 lg:order-1">
|
||||
<h2 className="mb-3 text-lg font-semibold">
|
||||
{isSeries ? "Staffeln" : "Wo läuft das?"}
|
||||
</h2>
|
||||
{isSeries ? (
|
||||
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
|
||||
) : (
|
||||
<div className="fl-card p-4">
|
||||
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="order-1 space-y-6 lg:order-2">
|
||||
<div className="fl-card p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Deine Bewertung
|
||||
</h2>
|
||||
<RatingPicker entryId={entry.id} rating={entry.rating} />
|
||||
</div>
|
||||
|
||||
<div className="fl-card p-4">
|
||||
<EntryNotes
|
||||
entryId={entry.id}
|
||||
notes={entry.notes}
|
||||
whereToWatch={entry.whereToWatch}
|
||||
tags={entry.tags.map((link) => link.tag.name)}
|
||||
suggestion={suggestion}
|
||||
owned={entry.owned}
|
||||
ownedSource={entry.ownedSource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSeries && (
|
||||
<div className="fl-card p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Wo läuft das?
|
||||
</h2>
|
||||
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fl-card p-4">
|
||||
<EntryActions
|
||||
entryId={entry.id}
|
||||
currentRun={entry.currentRun}
|
||||
canRewatch={entry.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!entry && (
|
||||
<>
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">Wo läuft das?</h2>
|
||||
<div className="fl-card p-4">
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
<p className="mt-3 text-xs text-[var(--fl-faint)]">
|
||||
Verfügbarkeitsdaten von JustWatch über TMDB, Region {user.region}.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isSeries && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">Staffeln</h2>
|
||||
<SeasonList seasons={seasons} />
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="mt-8 text-sm">
|
||||
<Link href="/bibliothek" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
|
||||
← Zur Bibliothek
|
||||
</Link>
|
||||
</p>
|
||||
</article>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||
<dd className="mt-0.5 font-medium">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||