Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57151a770e | ||
|
|
09625c89ef | ||
|
|
f8d5566865 | ||
|
|
6847d08011 | ||
|
|
7f890155ae | ||
|
|
b560ee9c51 | ||
|
|
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 |
@@ -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,393 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.9.0] — 2026-09-02
|
||||
|
||||
**Errungenschaften** — Abzeichen für erreichte Meilensteine. Dazu die
|
||||
**Auswahlliste** beim Jellyfin-Abgleich, ohne die die Anbindung auf einer
|
||||
gewachsenen Mediathek kaum etwas ausrichtete.
|
||||
|
||||
> ⚠️ **Beim Aktualisieren kommt eine Migration mit** (`errungenschaften`). Der
|
||||
> Container spielt sie beim Start selbst ein; `./update.sh` legt vorher eine
|
||||
> Sicherung an.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Errungenschaften.** Zwanzig feste Abzeichen, dazu dynamische für
|
||||
vollständig gesehene Filmreihen und abgeschlossene Universen. Zu finden
|
||||
unten auf der Statistikseite.
|
||||
|
||||
Sie tauchen auf, wenn etwas erreicht ist, und bleiben dann — **keine
|
||||
Streaks**, die reißen, wenn man eine Woche nichts schaut, keine
|
||||
Fortschrittsbalken, die drängeln. Wer eine Sichtung zurücknimmt, verliert
|
||||
kein Abzeichen; es wieder einzukassieren wäre kleinlich.
|
||||
|
||||
Ein paar davon: *Der lange Atem* (eine Serie mit hundert Episoden komplett),
|
||||
*Treu geblieben* (über ein Jahr an demselben Titel), *Durchgemacht*
|
||||
(zwischen drei und fünf Uhr morgens abgehakt), *Zeitreisender* (Titel aus
|
||||
sieben Jahrzehnten), *Strenges Urteil* (einmal die Note 1 vergeben).
|
||||
|
||||
Für **ganze Filmreihen** braucht es keine gepflegte Liste: TMDB gruppiert
|
||||
sie selbst, und die Zugehörigkeit steht beim Import schon am Titel. Wer alle
|
||||
Spider-Man-Filme gesehen hat, bekommt sie als Reihe angerechnet. Für
|
||||
bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht „Reihe
|
||||
komplett — …".
|
||||
|
||||
Beim ersten Öffnen wird die ganze Historie einmal **stumm** ausgewertet —
|
||||
eine gewachsene Bibliothek soll nicht zwei Dutzend Meldungen auf einmal
|
||||
auslösen. Erst danach wird gemeldet, was neu dazukommt. Auf dem öffentlichen
|
||||
Profil lassen sich die Abzeichen zeigen oder ausblenden.
|
||||
|
||||
- **Auswahlliste für Jellyfin-Titel, die Flimmer nicht kennt.** Beim ersten
|
||||
Abgleich einer gewachsenen Mediathek passierte bisher etwas Ernüchterndes:
|
||||
Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen einzigen** zu,
|
||||
weil der Katalog sie nicht kennt.
|
||||
|
||||
Jetzt steht unter dem Bericht eine Liste zum Anhaken. Was ausgewählt ist,
|
||||
holt Flimmer von TMDB — bei Serien samt Staffeln und Episoden — und nimmt es
|
||||
mit „hab ich · Jellyfin" in die Bibliothek auf.
|
||||
|
||||
Die Liste fasst zusammen: Führt Jellyfin drei Dateien zum selben Film
|
||||
(Extended Edition, zweite Fassung), steht er einmal da, mit dem Vermerk, wie
|
||||
viele Dateien darauf zeigen. Einträge, denen in Jellyfin die TMDB-Kennung
|
||||
fehlt, bleiben sichtbar, sind aber nicht auswählbar — dort kann Flimmer
|
||||
nichts holen.
|
||||
|
||||
Geholt werden hundert je Lauf; der Rest bleibt für den nächsten Klick. Ein
|
||||
einzelner Fehlschlag beendet den Lauf nicht, und **aus der Liste verschwindet
|
||||
nur, was tatsächlich ankam**.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Der Bericht des Medienserver-Abgleichs war auf **50 Einträge** gekürzt —
|
||||
bei über tausend gelesenen ein Blick durchs Schlüsselloch. Jetzt bis 1000,
|
||||
und was darüber hinausgeht, wird gezählt statt stillschweigend
|
||||
abgeschnitten.
|
||||
- Das Häkchen beim Abgleich heißt jetzt „**Bekannte** Titel dabei in die
|
||||
Bibliothek aufnehmen". Es nimmt auf, was Flimmer *kennt*; die neue
|
||||
Auswahlliste holt, was es *nicht kennt*. Der Unterschied stand vorher
|
||||
nirgends — und war der Grund, warum das Häkchen bei einer frischen Instanz
|
||||
nichts bewirkte.
|
||||
- Einblendungen kennen einen dritten Ton für Erfolge.
|
||||
- `npm run screenshots` hebt für Ausschnittaufnahmen die klebende Kopfleiste
|
||||
auf; sie legte sich sonst über die Überschrift.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Im Bericht stand eine Serie als **Film**, weil der Typ aus dem Umkehrschluss
|
||||
„keine Episode, also Film" abgeleitet wurde. Er kommt jetzt aus dem Item.
|
||||
|
||||
### Getestet
|
||||
|
||||
93 Tests (vorher 78). Neu: der Regelkatalog der Errungenschaften samt
|
||||
Stummschaltung, Entdopplung und Zeitzonenrechnung; die Auswahlliste samt
|
||||
Zusammenfassung, Fehlschlägen und Rechteprüfung.
|
||||
|
||||
Ein Test hat dabei einen Fehler gefunden, den man leicht übersieht: Die Regel
|
||||
„zwischen drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette,
|
||||
und auf Deutsch liefert das System dafür **„03 Uhr"** — als Zahl gelesen wird
|
||||
daraus nichts. Das Abzeichen wäre nie vergeben worden.
|
||||
|
||||
### Anleitung
|
||||
|
||||
`docs/ANLEITUNG.md` hat zwei neue Abschnitte: **Errungenschaften** mit
|
||||
Screenshot und **Titel, die Flimmer noch nicht kennt** im Jellyfin-Kapitel.
|
||||
|
||||
|
||||
## [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.
|
||||
@@ -168,7 +555,11 @@ 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.4.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
|
||||
|
||||
@@ -96,12 +96,62 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
||||
- **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
|
||||
|
||||
- ✅ **Etappe 1** (v0.1.0, released) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
|
||||
- ⏳ **Etappe 2** — TMDB-Anbindung
|
||||
- ✅ **Etappe 1** (v0.1.0) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
|
||||
- ✅ **Etappe 2** (v0.2.0) — TMDB: Suche, Import, Episoden, Bild-Cache. 14 min.
|
||||
- ✅ **Etappe 3** (v0.3.0) — Bibliothek, Status, Episodenfortschritt, Papierkorb. 24 min.
|
||||
- ✅ **Etappe 4** (v0.4.0) — Universen mit Reihenfolgen, Freigaben. 14 min.
|
||||
- ✅ **Etappe 5** (v0.5.0) — Übersicht, Kalender, Statistik. 15 min.
|
||||
- ✅ **Etappe 6** (v0.6.0) — Benachrichtigungen, Abgleich, Import/Export, REST-API, PWA, Anleitung. 31 min.
|
||||
|
||||
**Alle sechs Etappen abgeschlossen. Gesamtzeit: 2 h 16 min.**
|
||||
|
||||
- ✅ **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.
|
||||
- ✅ **Zweite Runde** (v0.9.0) — Errungenschaften, Auswahlliste für unbekannte
|
||||
Jellyfin-Titel. 37 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 -->
|
||||
|
||||
|
||||
@@ -62,6 +62,125 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## Zweite Issue-Runde (v0.9.0)
|
||||
|
||||
| # | Issue | Dauer | Was passiert ist |
|
||||
|---|---|---|---|
|
||||
| 9.1 | #15 Errungenschaften | 22 min | Regelkatalog auf einem gemeinsamen Datensatz; Filmreihen über die TMDB-Zugehörigkeit statt einer gepflegten Liste |
|
||||
| 9.2 | #16 Titel nachladen | 15 min | Aus dem Bericht wird eine Auswahlliste; was Flimmer nicht kennt, kommt auf Wunsch aus TMDB |
|
||||
|
||||
**Issues gesamt: 37 min** (2026-09-02, 09:47 – 11:13)
|
||||
|
||||
Dazu ohne Zeiterfassung: zwei Issues angelegt (#15 und #16), diese Version
|
||||
selbst und die Nachträge an der Anleitung.
|
||||
|
||||
### Was dabei auffiel
|
||||
|
||||
1. **Ein Test hat eine Formatierungsfalle aufgedeckt.** Die Regel „zwischen
|
||||
drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette; auf
|
||||
Deutsch liefert `Intl` dafür „03 Uhr", und `Number("03 Uhr")` ist `NaN`.
|
||||
Das Abzeichen wäre nie vergeben worden.
|
||||
2. **Ich hatte ein fragwürdiges Verhalten in einem Test festgeschrieben.**
|
||||
Der erste Entwurf strich auch *gescheiterte* Titel aus der Auswahlliste,
|
||||
und der Test hielt das fest. Erst beim Durchklicken fiel auf, wie ärgerlich
|
||||
das wäre — Code und Test wurden korrigiert.
|
||||
3. **Ein Klickweg hat eine falsche Beschriftung gezeigt.** Eine Serie stand
|
||||
als „Film" in der Liste, weil der Typ aus dem Umkehrschluss „keine Episode,
|
||||
also Film" kam.
|
||||
4. **Der Entwicklungs-Stub kann nur suchen.** Er speist sich aus dem lokalen
|
||||
Katalog und kann deshalb per Definition nichts liefern, was dort fehlt —
|
||||
der Importpfad ließ sich nur über Fixtures belegen, nicht über die
|
||||
Oberfläche.
|
||||
5. **Zwei parallele Branches, kein Konflikt.** Beide zweigten von `main` ab
|
||||
und fassten dieselben drei Dateien an. Der Merge lief glatt; geprüft wurde
|
||||
trotzdem die Kombination auf `main`, nicht nur die einzelnen Zweige.
|
||||
|
||||
---
|
||||
|
||||
## Gesamtübersicht
|
||||
|
||||
| Etappe | Version | Dauer |
|
||||
@@ -70,5 +189,42 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
| 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 |
|
||||
| Zweite Runde — Errungenschaften, Titel nachladen | 0.9.0 | 37 min |
|
||||
|
||||
**Bisher gesamt: 90 min**
|
||||
**Gesamtentwicklungszeit: 5 h 01 min** (301 min, 2026-09-01 18:09 – 2026-09-02 11:13)
|
||||
|
||||
### 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,561 @@
|
||||
<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.
|
||||
|
||||
---
|
||||
|
||||
## Errungenschaften
|
||||
|
||||

|
||||
|
||||
Unten auf der Statistikseite sammeln sich **Abzeichen**. Sie tauchen auf, wenn
|
||||
etwas erreicht ist — mehr passiert nicht. Keine Serien, die reißen, wenn du
|
||||
eine Woche nichts schaust, keine Balken, die dich antreiben.
|
||||
|
||||
Ein paar davon:
|
||||
|
||||
- **Abgehakt** — die erste Serie vollständig durchgeschaut
|
||||
- **Der lange Atem** — eine Serie mit mindestens hundert Episoden komplett
|
||||
- **Treu geblieben** — über ein Jahr an demselben Titel drangeblieben
|
||||
- **Durchgemacht** — zwischen drei und fünf Uhr morgens etwas abgehakt
|
||||
- **Zeitreisender** — Titel aus sieben Jahrzehnten gesehen
|
||||
- **Strenges Urteil** — einmal die Note 1 vergeben
|
||||
|
||||
Dazu kommen Abzeichen für **ganze Filmreihen**: Wer alle Spider-Man-Filme
|
||||
gesehen hat, bekommt sie als solche angerechnet. Flimmer erkennt das an der
|
||||
TMDB-Kennung, die zu jedem Film mitgeliefert wird — es braucht keine gepflegte
|
||||
Liste. Für bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht
|
||||
„Reihe komplett — …". Genauso zählen **eigene Universen**, sobald alle Titel
|
||||
darin abgeschlossen sind.
|
||||
|
||||
> **Einmal erreicht, bleibt erreicht.**
|
||||
> Nimmst du eine Sichtung zurück oder entfernst einen Titel, verlierst du kein
|
||||
> Abzeichen. Es wieder einzukassieren wäre kleinlich — und ein Fehlklick soll
|
||||
> nichts kaputt machen.
|
||||
|
||||
Beim ersten Öffnen der Seite schaut Flimmer einmal deine ganze Historie durch.
|
||||
Wenn du Flimmer schon länger nutzt, sind deshalb sofort mehrere Abzeichen da —
|
||||
ohne dass du dafür eine Flut von Meldungen bekommst. Erst danach wird gemeldet,
|
||||
was neu dazukommt.
|
||||
|
||||
Wenn du ein öffentliches Profil hast, kannst du die Abzeichen dort zeigen oder
|
||||
ausblenden — der Schalter steht in den Einstellungen direkt bei der
|
||||
Profiladresse.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### Titel, die Flimmer noch nicht kennt
|
||||
|
||||
Beim ersten Abgleich einer gewachsenen Mediathek passiert oft etwas
|
||||
Ernüchterndes: Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen
|
||||
einzigen** zu. Das ist richtig so — Flimmer kennt nur, was du selbst
|
||||
aufgenommen oder über die Suche importiert hast.
|
||||
|
||||
Deshalb steht unter dem Bericht eine **Auswahlliste**: alles, was auf dem
|
||||
Server liegt und Flimmer unbekannt ist. Hake an, was übernommen werden soll,
|
||||
und klicke auf Übernehmen — die Daten holt Flimmer dann von TMDB, bei Serien
|
||||
samt Staffeln und Episoden.
|
||||
|
||||
Zwei Dinge, die dabei helfen:
|
||||
|
||||
- **Zusammengefasst.** Führt Jellyfin drei Dateien zum selben Film (Extended
|
||||
Edition, zweite Fassung), steht er trotzdem nur einmal in der Liste — mit
|
||||
dem Vermerk, wie viele Dateien darauf zeigen.
|
||||
- **Es werden hundert auf einmal geholt.** Wer mehr auswählt, bekommt den Rest
|
||||
beim nächsten Klick. Jeder Titel bedeutet einen Abruf bei TMDB, bei Serien
|
||||
einen je Staffel.
|
||||
|
||||
Manche Einträge lassen sich **nicht** anhaken: dort fehlt die TMDB-Kennung in
|
||||
Jellyfin selbst. Das trifft oft Extended Editions, Konzertmitschnitte oder
|
||||
Dateien mit eigenwilliger Benennung. Meist hilft es, in Jellyfin die Erkennung
|
||||
für diesen Titel nachzuziehen — danach taucht er beim nächsten Abgleich mit
|
||||
Kennung auf.
|
||||
|
||||
> **Der Unterschied zwischen den beiden Häkchen**
|
||||
> „Bekannte Titel dabei in die Bibliothek aufnehmen" beim Abgleich nimmt auf,
|
||||
> was Flimmer **kennt**, aber noch nicht in deiner Bibliothek steht.
|
||||
> Die Auswahlliste holt, was Flimmer **nicht kennt**.
|
||||
|
||||
**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: 121 KiB |
|
After Width: | Height: | Size: 31 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 |
|
After Width: | Height: | Size: 46 KiB |
@@ -14,12 +14,8 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
poweredByHeader: false,
|
||||
serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"],
|
||||
// Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über
|
||||
// next/image-Remote-Loader — deshalb hier keine remotePatterns nötig.
|
||||
images: {
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: "attachment",
|
||||
},
|
||||
// Bilder laufen über den eigenen Cache-Endpunkt (/api/image), nicht über
|
||||
// next/image — deshalb ist hier nichts zu konfigurieren.
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,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.4.0",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||
"license": "MIT",
|
||||
@@ -15,12 +15,15 @@
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"icons": "tsx scripts/generate-icons.ts",
|
||||
"screenshots": "tsx scripts/screenshots.ts",
|
||||
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts"
|
||||
"screenshots": "node scripts/screenshots.mjs && node scripts/optimize-screenshots.mjs",
|
||||
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts",
|
||||
"tmdb:stub": "node --import tsx scripts/tmdb-stub.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@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",
|
||||
@@ -35,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",
|
||||
@@ -50,5 +52,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@7.10.0": true,
|
||||
"@prisma/engines@7.10.0": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LibraryEntry" ADD COLUMN "owned" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "ownedSource" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LibraryEntry_userId_owned_idx" ON "LibraryEntry"("userId", "owned");
|
||||
@@ -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;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "showAchievements" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Achievement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
"unlockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Achievement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Achievement_userId_unlockedAt_idx" ON "Achievement"("userId", "unlockedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Achievement_userId_key_key" ON "Achievement"("userId", "key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Achievement" ADD CONSTRAINT "Achievement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -42,6 +42,9 @@ model User {
|
||||
publicProfile Boolean @default(false)
|
||||
profileSlug String? @unique
|
||||
|
||||
/// Errungenschaften auf der öffentlichen Profilseite zeigen
|
||||
showAchievements Boolean @default(true)
|
||||
|
||||
// Benachrichtigungswünsche
|
||||
notifyNewEpisode Boolean @default(true)
|
||||
notifyRelease Boolean @default(true)
|
||||
@@ -53,6 +56,9 @@ model User {
|
||||
sessions Session[]
|
||||
apiKeys ApiKey[]
|
||||
oidcAccounts OidcAccount[]
|
||||
passkeys Passkey[]
|
||||
mediaServers MediaServerConnection[]
|
||||
achievements Achievement[]
|
||||
entries LibraryEntry[]
|
||||
watches Watch[]
|
||||
tags Tag[]
|
||||
@@ -109,6 +115,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 +212,7 @@ model Title {
|
||||
watches Watch[]
|
||||
providers TitleProvider[]
|
||||
universeItems UniverseItem[]
|
||||
serverItems MediaServerItem[]
|
||||
|
||||
@@unique([type, tmdbId])
|
||||
@@index([type])
|
||||
@@ -219,6 +257,7 @@ model Episode {
|
||||
runtime Int?
|
||||
|
||||
watches Watch[]
|
||||
serverItems MediaServerItem[]
|
||||
|
||||
@@unique([titleId, seasonNumber, episodeNumber])
|
||||
@@index([titleId])
|
||||
@@ -289,6 +328,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 +355,7 @@ model LibraryEntry {
|
||||
@@unique([userId, titleId])
|
||||
@@index([userId, status])
|
||||
@@index([userId, updatedAt])
|
||||
@@index([userId, owned])
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
@@ -351,6 +398,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 +560,99 @@ 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])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Errungenschaften
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ein erreichtes Abzeichen. Einmal vergeben, bleibt es — auch wenn die
|
||||
/// Bedingung später nicht mehr erfuellt waere.
|
||||
model Achievement {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Stabiler Schluessel: "serientaeter-1000", "reihe:556", "universum:<id>".
|
||||
key String
|
||||
|
||||
/// Bei dynamisch benannten Abzeichen der Name zum Zeitpunkt des Erreichens.
|
||||
/// Eingefroren: ein umbenanntes Universum soll das Abzeichen nicht aendern.
|
||||
label String?
|
||||
|
||||
unlockedAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, key])
|
||||
@@index([userId, unlockedAt])
|
||||
}
|
||||
|
||||
@@ -244,6 +244,9 @@ type EntryPlan = {
|
||||
startedDaysAgo?: number;
|
||||
/** Abstand zwischen zwei Episoden in Tagen. */
|
||||
spacingDays?: number;
|
||||
/** Liegt der Titel in der eigenen Sammlung? */
|
||||
owned?: boolean;
|
||||
ownedSource?: string;
|
||||
};
|
||||
|
||||
const ENTRY_PLANS: EntryPlan[] = [
|
||||
@@ -315,8 +318,8 @@ const ENTRY_PLANS: EntryPlan[] = [
|
||||
},
|
||||
{ key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] },
|
||||
|
||||
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340 },
|
||||
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320 },
|
||||
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340, owned: true, ownedSource: "Jellyfin" },
|
||||
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320, owned: true, ownedSource: "Jellyfin" },
|
||||
{ key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 },
|
||||
{ key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] },
|
||||
{ key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] },
|
||||
@@ -330,7 +333,7 @@ const ENTRY_PLANS: EntryPlan[] = [
|
||||
startedDaysAgo: 180,
|
||||
},
|
||||
{ key: "stille-bilder-2", status: "WATCHLIST" },
|
||||
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60 },
|
||||
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60, owned: true, ownedSource: "Blu-ray" },
|
||||
{ key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 },
|
||||
{ key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 },
|
||||
];
|
||||
@@ -355,6 +358,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
|
||||
rating: plan.rating ?? null,
|
||||
notes: plan.notes ?? null,
|
||||
favorite: plan.favorite ?? false,
|
||||
owned: plan.owned ?? false,
|
||||
ownedSource: plan.ownedSource ?? null,
|
||||
currentRun,
|
||||
startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null,
|
||||
finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null,
|
||||
@@ -364,6 +369,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
|
||||
rating: plan.rating ?? null,
|
||||
notes: plan.notes ?? null,
|
||||
favorite: plan.favorite ?? false,
|
||||
owned: plan.owned ?? false,
|
||||
ownedSource: plan.ownedSource ?? null,
|
||||
currentRun,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Flimmer — Service Worker
|
||||
*
|
||||
* Bewusst handgeschrieben und knapp gehalten. Drei Strategien:
|
||||
*
|
||||
* Bilder und statische Dateien → zuerst aus dem Zwischenspeicher.
|
||||
* Poster ändern sich unter einem TMDB-Pfad nie, das spart jede Anfrage.
|
||||
* Seitenaufrufe → zuerst das Netz, sonst der Speicher,
|
||||
* sonst die Offline-Seite. Ein Tracker, der veraltete Stände zeigt, wäre
|
||||
* schlimmer als einer, der ehrlich sagt: gerade keine Verbindung.
|
||||
* Alles Übrige (API, Aktionen) → gar kein Zwischenspeicher.
|
||||
*
|
||||
* Schreibende Anfragen laufen nie über den Service Worker.
|
||||
*/
|
||||
|
||||
const VERSION = "flimmer-v1";
|
||||
const SHELL_CACHE = `${VERSION}-shell`;
|
||||
const IMAGE_CACHE = `${VERSION}-images`;
|
||||
const PAGE_CACHE = `${VERSION}-pages`;
|
||||
|
||||
const OFFLINE_URL = "/offline";
|
||||
|
||||
const SHELL = [OFFLINE_URL, "/favicon.svg", "/icons/icon-192.png", "/manifest.webmanifest"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(SHELL_CACHE)
|
||||
.then((cache) => cache.addAll(SHELL))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(keys.filter((key) => !key.startsWith(VERSION)).map((key) => caches.delete(key))),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
/** Zwischenspeicher begrenzen, damit Poster ihn nicht endlos füllen. */
|
||||
async function trim(cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
if (keys.length <= maxEntries) return;
|
||||
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
|
||||
}
|
||||
|
||||
async function cacheFirst(request, cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(request, response.clone());
|
||||
void trim(cacheName, maxEntries);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
const cache = await caches.open(PAGE_CACHE);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
await cache.put(request, response.clone());
|
||||
void trim(PAGE_CACHE, 40);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const hit = await cache.match(request);
|
||||
if (hit) return hit;
|
||||
|
||||
const offline = await caches.match(OFFLINE_URL);
|
||||
if (offline) return offline;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Bilder aus dem eigenen Cache-Endpunkt und statische Next-Dateien
|
||||
if (url.pathname.startsWith("/api/image")) {
|
||||
event.respondWith(cacheFirst(request, IMAGE_CACHE, 400));
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith("/_next/static") || url.pathname.startsWith("/icons/")) {
|
||||
event.respondWith(cacheFirst(request, SHELL_CACHE, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
// Übrige API-Aufrufe nie zwischenspeichern
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(networkFirst(request));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,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,91 @@
|
||||
# Flimmer 0.9.0
|
||||
|
||||
**Errungenschaften** — Abzeichen für erreichte Meilensteine. Dazu die
|
||||
**Auswahlliste** beim Jellyfin-Abgleich, ohne die die Anbindung auf einer
|
||||
gewachsenen Mediathek kaum etwas ausrichtete.
|
||||
|
||||
> ⚠️ **Beim Aktualisieren kommt eine Migration mit** (`errungenschaften`). Der
|
||||
> Container spielt sie beim Start selbst ein; `./update.sh` legt vorher eine
|
||||
> Sicherung an.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Errungenschaften.** Zwanzig feste Abzeichen, dazu dynamische für
|
||||
vollständig gesehene Filmreihen und abgeschlossene Universen. Zu finden
|
||||
unten auf der Statistikseite.
|
||||
|
||||
Sie tauchen auf, wenn etwas erreicht ist, und bleiben dann — **keine
|
||||
Streaks**, die reißen, wenn man eine Woche nichts schaut, keine
|
||||
Fortschrittsbalken, die drängeln. Wer eine Sichtung zurücknimmt, verliert
|
||||
kein Abzeichen; es wieder einzukassieren wäre kleinlich.
|
||||
|
||||
Ein paar davon: *Der lange Atem* (eine Serie mit hundert Episoden komplett),
|
||||
*Treu geblieben* (über ein Jahr an demselben Titel), *Durchgemacht*
|
||||
(zwischen drei und fünf Uhr morgens abgehakt), *Zeitreisender* (Titel aus
|
||||
sieben Jahrzehnten), *Strenges Urteil* (einmal die Note 1 vergeben).
|
||||
|
||||
Für **ganze Filmreihen** braucht es keine gepflegte Liste: TMDB gruppiert
|
||||
sie selbst, und die Zugehörigkeit steht beim Import schon am Titel. Wer alle
|
||||
Spider-Man-Filme gesehen hat, bekommt sie als Reihe angerechnet. Für
|
||||
bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht „Reihe
|
||||
komplett — …".
|
||||
|
||||
Beim ersten Öffnen wird die ganze Historie einmal **stumm** ausgewertet —
|
||||
eine gewachsene Bibliothek soll nicht zwei Dutzend Meldungen auf einmal
|
||||
auslösen. Erst danach wird gemeldet, was neu dazukommt. Auf dem öffentlichen
|
||||
Profil lassen sich die Abzeichen zeigen oder ausblenden.
|
||||
|
||||
- **Auswahlliste für Jellyfin-Titel, die Flimmer nicht kennt.** Beim ersten
|
||||
Abgleich einer gewachsenen Mediathek passierte bisher etwas Ernüchterndes:
|
||||
Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen einzigen** zu,
|
||||
weil der Katalog sie nicht kennt.
|
||||
|
||||
Jetzt steht unter dem Bericht eine Liste zum Anhaken. Was ausgewählt ist,
|
||||
holt Flimmer von TMDB — bei Serien samt Staffeln und Episoden — und nimmt es
|
||||
mit „hab ich · Jellyfin" in die Bibliothek auf.
|
||||
|
||||
Die Liste fasst zusammen: Führt Jellyfin drei Dateien zum selben Film
|
||||
(Extended Edition, zweite Fassung), steht er einmal da, mit dem Vermerk, wie
|
||||
viele Dateien darauf zeigen. Einträge, denen in Jellyfin die TMDB-Kennung
|
||||
fehlt, bleiben sichtbar, sind aber nicht auswählbar — dort kann Flimmer
|
||||
nichts holen.
|
||||
|
||||
Geholt werden hundert je Lauf; der Rest bleibt für den nächsten Klick. Ein
|
||||
einzelner Fehlschlag beendet den Lauf nicht, und **aus der Liste verschwindet
|
||||
nur, was tatsächlich ankam**.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Der Bericht des Medienserver-Abgleichs war auf **50 Einträge** gekürzt —
|
||||
bei über tausend gelesenen ein Blick durchs Schlüsselloch. Jetzt bis 1000,
|
||||
und was darüber hinausgeht, wird gezählt statt stillschweigend
|
||||
abgeschnitten.
|
||||
- Das Häkchen beim Abgleich heißt jetzt „**Bekannte** Titel dabei in die
|
||||
Bibliothek aufnehmen". Es nimmt auf, was Flimmer *kennt*; die neue
|
||||
Auswahlliste holt, was es *nicht kennt*. Der Unterschied stand vorher
|
||||
nirgends — und war der Grund, warum das Häkchen bei einer frischen Instanz
|
||||
nichts bewirkte.
|
||||
- Einblendungen kennen einen dritten Ton für Erfolge.
|
||||
- `npm run screenshots` hebt für Ausschnittaufnahmen die klebende Kopfleiste
|
||||
auf; sie legte sich sonst über die Überschrift.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Im Bericht stand eine Serie als **Film**, weil der Typ aus dem Umkehrschluss
|
||||
„keine Episode, also Film" abgeleitet wurde. Er kommt jetzt aus dem Item.
|
||||
|
||||
### Getestet
|
||||
|
||||
93 Tests (vorher 78). Neu: der Regelkatalog der Errungenschaften samt
|
||||
Stummschaltung, Entdopplung und Zeitzonenrechnung; die Auswahlliste samt
|
||||
Zusammenfassung, Fehlschlägen und Rechteprüfung.
|
||||
|
||||
Ein Test hat dabei einen Fehler gefunden, den man leicht übersieht: Die Regel
|
||||
„zwischen drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette,
|
||||
und auf Deutsch liefert das System dafür **„03 Uhr"** — als Zahl gelesen wird
|
||||
daraus nichts. Das Abzeichen wäre nie vergeben worden.
|
||||
|
||||
### Anleitung
|
||||
|
||||
`docs/ANLEITUNG.md` hat zwei neue Abschnitte: **Errungenschaften** mit
|
||||
Screenshot und **Titel, die Flimmer noch nicht kennt** im Jellyfin-Kapitel.
|
||||
@@ -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,235 @@
|
||||
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);
|
||||
|
||||
// Die Kopfleiste klebt oben und legt sich sonst über die Überschrift des
|
||||
// Ausschnitts. Fürs Bild kurz aufheben.
|
||||
await page.addStyleTag({ content: "header { position: static !important; }" }).catch(() => {});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// --- Errungenschaften ---
|
||||
{
|
||||
const { ctx, page } = await session(1400, 1200, "dark");
|
||||
await login(page);
|
||||
await page.goto(`${BASE}/statistik`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(900);
|
||||
await ausschnitt(page, "23-errungenschaften", "Errungenschaften");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// --- Hell ---
|
||||
{
|
||||
const { ctx, page } = await session(1400, 900, "light");
|
||||
await login(page);
|
||||
await shot(page, "08-hell", "/bibliothek");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// --- Mobil ---
|
||||
{
|
||||
const { ctx, page } = await session(430, 900, "dark");
|
||||
await login(page);
|
||||
await shot(page, "17-mobil-uebersicht", "/");
|
||||
await shot(page, "18-mobil-serie", "/bibliothek");
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
console.log("Fehler:", errs.length ? [...new Set(errs)].slice(0, 5) : "keine");
|
||||
await browser.close();
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Kleiner TMDB-Ersatz für Screenshots und manuelles Ausprobieren.
|
||||
*
|
||||
* node scripts/tmdb-stub.mjs [port]
|
||||
*
|
||||
* Beantwortet die Suchendpunkte aus dem lokalen Katalog (den das Seed-Skript
|
||||
* angelegt hat) im Format der TMDB-API. Damit lässt sich die Suchseite auch
|
||||
* ohne echten TMDB-Schlüssel zeigen — für Screenshots der Anleitung und um
|
||||
* die Oberfläche auszuprobieren, bevor man einen Schlüssel besorgt.
|
||||
*
|
||||
* Verwendung zusammen mit:
|
||||
* TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "../src/generated/prisma/client.ts";
|
||||
|
||||
try {
|
||||
process.loadEnvFile(".env");
|
||||
} catch {
|
||||
// Werte kommen aus der Umgebung.
|
||||
}
|
||||
|
||||
const port = Number(process.argv[2] ?? 3099);
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
|
||||
function toResult(title, mediaType) {
|
||||
const date = title.releaseDate?.toISOString().slice(0, 10) ?? null;
|
||||
return {
|
||||
id: title.tmdbId,
|
||||
media_type: mediaType,
|
||||
name: mediaType === "tv" ? title.title : undefined,
|
||||
title: mediaType === "movie" ? title.title : undefined,
|
||||
original_name: title.originalTitle ?? undefined,
|
||||
original_title: title.originalTitle ?? undefined,
|
||||
overview: title.overview ?? "",
|
||||
poster_path: title.posterPath,
|
||||
backdrop_path: title.backdropPath,
|
||||
first_air_date: mediaType === "tv" ? date : undefined,
|
||||
release_date: mediaType === "movie" ? date : undefined,
|
||||
vote_average: title.voteAverage ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function search(query, type) {
|
||||
const titles = await prisma.title.findMany({
|
||||
where: {
|
||||
tmdbId: { not: null },
|
||||
...(type ? { type } : {}),
|
||||
OR: [
|
||||
{ title: { contains: query, mode: "insensitive" } },
|
||||
{ originalTitle: { contains: query, mode: "insensitive" } },
|
||||
{ genres: { has: query } },
|
||||
],
|
||||
},
|
||||
orderBy: { releaseDate: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return titles.map((title) => toResult(title, title.type === "SERIES" ? "tv" : "movie"));
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
|
||||
const query = url.searchParams.get("query") ?? "";
|
||||
|
||||
const send = (body, status = 200) => {
|
||||
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
try {
|
||||
if (url.pathname.startsWith("/3/search/")) {
|
||||
const kind = url.pathname.split("/").pop();
|
||||
const type = kind === "tv" ? "SERIES" : kind === "movie" ? "MOVIE" : null;
|
||||
const results = await search(query, type);
|
||||
return send({ page: 1, results, total_pages: 1, total_results: results.length });
|
||||
}
|
||||
|
||||
return send({ status_message: `Kein Ersatz für ${url.pathname}` }, 404);
|
||||
} catch (error) {
|
||||
return send({ status_message: String(error) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`TMDB-Ersatz läuft auf http://127.0.0.1:${port}/3`);
|
||||
});
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
server.close();
|
||||
void prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
startRewatch,
|
||||
undoWatches,
|
||||
unmarkEpisodes,
|
||||
unmarkMovie,
|
||||
unmarkSeason,
|
||||
updateEntryDetails,
|
||||
watchNextEpisode,
|
||||
type WatchResult,
|
||||
} from "@/lib/library";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { achievementName, evaluateAchievements } from "@/lib/achievements";
|
||||
|
||||
/**
|
||||
* Server-Aktionen rund um die Bibliothek.
|
||||
@@ -37,11 +39,14 @@ export type ActionResult = {
|
||||
message?: string;
|
||||
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
|
||||
watchIds?: string[];
|
||||
/** Gerade erreichte Errungenschaften, fertig benannt. */
|
||||
achievements?: string[];
|
||||
};
|
||||
|
||||
async function run(
|
||||
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
|
||||
revalidate: string[] = [],
|
||||
{ pruefeErrungenschaften = false }: { pruefeErrungenschaften?: boolean } = {},
|
||||
): Promise<ActionResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
@@ -49,7 +54,25 @@ async function run(
|
||||
try {
|
||||
const result = (await fn(user.id)) ?? {};
|
||||
for (const path of revalidate) revalidatePath(path, "layout");
|
||||
return { ok: true, message: result.message, watchIds: result.watchIds };
|
||||
|
||||
// Nur nach Sichtungsaktionen nachsehen — eine Tag-Änderung kann kein
|
||||
// Abzeichen auslösen, und die Auswertung liest die ganze Sichtungshistorie.
|
||||
let achievements: string[] | undefined;
|
||||
if (pruefeErrungenschaften) {
|
||||
try {
|
||||
const ergebnis = await evaluateAchievements(user.id);
|
||||
if (ergebnis.neu.length > 0) {
|
||||
achievements = ergebnis.neu.map((eintrag) =>
|
||||
achievementName(eintrag.key, eintrag.label),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ein Abzeichen ist die Kür. Es darf das Abhaken nie scheitern lassen.
|
||||
console.error("Errungenschaften konnten nicht ausgewertet werden:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, message: result.message, watchIds: result.watchIds, achievements };
|
||||
} catch (error) {
|
||||
if (error instanceof LibraryError) return { ok: false, message: error.message };
|
||||
console.error("Bibliotheksaktion fehlgeschlagen:", error);
|
||||
@@ -74,7 +97,7 @@ export async function addTitleAction(titleId: string, status: string = "WATCHLIS
|
||||
message:
|
||||
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
|
||||
};
|
||||
}, PATHS);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
export async function trashEntryAction(entryId: string) {
|
||||
@@ -114,6 +137,8 @@ const detailsSchema = z.object({
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
whereToWatch: z.string().max(120).nullable().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
owned: z.boolean().optional(),
|
||||
ownedSource: z.string().max(80).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function updateEntryAction(entryId: string, data: unknown) {
|
||||
@@ -127,7 +152,7 @@ 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);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -145,7 +170,7 @@ export async function toggleEpisodeAction(entryId: string, episodeId: string, wa
|
||||
}
|
||||
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
|
||||
return { message: "Markierung entfernt" };
|
||||
}, PATHS);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
|
||||
@@ -155,29 +180,37 @@ export async function toggleSeasonAction(entryId: string, seasonId: string, watc
|
||||
}
|
||||
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
|
||||
return { message: "Staffel zurückgesetzt" };
|
||||
}, PATHS);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
export async function markUpToAction(entryId: string, episodeId: string) {
|
||||
return run(
|
||||
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
|
||||
PATHS,
|
||||
{ pruefeErrungenschaften: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function watchNextAction(entryId: string) {
|
||||
return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS);
|
||||
return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
export async function markMovieAction(entryId: string) {
|
||||
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
|
||||
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
export async function unmarkMovieAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
await unmarkMovie(userId, cuid.parse(entryId));
|
||||
return { message: "Markierung entfernt" };
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
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);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -224,7 +257,7 @@ export async function setTagsAction(entryId: string, tagNames: string[]) {
|
||||
]);
|
||||
|
||||
return { message: "Tags gespeichert" };
|
||||
}, PATHS);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
"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,
|
||||
importUnassigned,
|
||||
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(),
|
||||
showAchievements: 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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const auswahlSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
tmdbId: z.number().int().positive(),
|
||||
typ: z.enum(["SERIES", "MOVIE"]),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(1000);
|
||||
|
||||
/** Holt die ausgewählten Titel aus TMDB nach. */
|
||||
export async function importMediaServerTitlesAction(
|
||||
connectionId: string,
|
||||
auswahl: unknown,
|
||||
): Promise<MediaServerResult & { uebrig?: number }> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
const daten = auswahlSchema.safeParse(auswahl);
|
||||
if (!daten.success) return { ok: false, message: "Es war nichts ausgewählt." };
|
||||
|
||||
try {
|
||||
const ergebnis = await importUnassigned(user.id, connectionId, daten.data);
|
||||
revalidatePath("/einstellungen", "layout");
|
||||
revalidatePath("/bibliothek", "layout");
|
||||
|
||||
const teile = [`${ergebnis.nachgeladen} Titel nachgeladen`];
|
||||
if (ergebnis.fehler.length > 0) teile.push(`${ergebnis.fehler.length} fehlgeschlagen`);
|
||||
if (ergebnis.uebrig > 0) teile.push(`${ergebnis.uebrig} bleiben für den nächsten Lauf`);
|
||||
|
||||
return { ok: true, message: teile.join(", "), uebrig: ergebnis.uebrig };
|
||||
} 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" };
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
removeTitle,
|
||||
renameOrder,
|
||||
renameUniverse,
|
||||
setDefaultOrder,
|
||||
setOrderPositions,
|
||||
shareUniverse,
|
||||
trashUniverse,
|
||||
@@ -124,6 +125,13 @@ export async function deleteOrderAction(orderId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -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,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 };
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type Libr
|
||||
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" };
|
||||
@@ -29,6 +30,7 @@ export default async function LibraryPage({
|
||||
genre: params.genre || undefined,
|
||||
tag: params.tag || undefined,
|
||||
provider: params.dienst || undefined,
|
||||
owned: params.sammlung === "1" ? true : undefined,
|
||||
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
|
||||
};
|
||||
|
||||
@@ -55,8 +57,10 @@ export default async function LibraryPage({
|
||||
</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">
|
||||
@@ -88,6 +92,8 @@ export default async function LibraryPage({
|
||||
</>
|
||||
)}
|
||||
</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,110 @@
|
||||
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,
|
||||
showAchievements: 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,
|
||||
// Der letzte Bericht liegt als Json in der Datenbank; daraus
|
||||
// speist sich die Auswahlliste, damit sie einen Seitenwechsel
|
||||
// übersteht.
|
||||
offen: (verbindung.lastSyncReport as { nichtZugeordnet?: unknown[] } | null)
|
||||
?.nichtZugeordnet as never ?? [],
|
||||
weitereOffen:
|
||||
((verbindung.lastSyncReport as { weitereNichtZugeordnet?: number } | null)
|
||||
?.weitereNichtZugeordnet ?? 0),
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,18 +1,30 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Outfit } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import { BrandDefs } from "@/components/brand/FlimmerLogo";
|
||||
import { ToastProvider } from "@/components/Toasts";
|
||||
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
|
||||
import { env } from "@/lib/env";
|
||||
import "./globals.css";
|
||||
|
||||
/**
|
||||
* Outfit wird beim Bauen heruntergeladen und mit ausgeliefert — die laufende
|
||||
* Instanz fragt also niemals bei Google an.
|
||||
* Outfit liegt als Datei im Repository.
|
||||
*
|
||||
* next/font/google würde die Schrift beim Bauen herunterladen — das macht den
|
||||
* Build von einer Netzverbindung abhängig und ließ ihn im Container hängen.
|
||||
* Für eine selbst gehostete Anwendung ist die mitgelieferte Datei ohnehin das
|
||||
* Richtige: kein Abruf beim Bauen, keiner zur Laufzeit.
|
||||
*
|
||||
* Es ist die variable Fassung (Strichstärke 100–900), aufgeteilt in Latin und
|
||||
* Latin-Extended — der Browser lädt nur, was die Seite wirklich braucht.
|
||||
*/
|
||||
const outfit = Outfit({
|
||||
subsets: ["latin"],
|
||||
const outfit = localFont({
|
||||
src: [
|
||||
{ path: "../../public/fonts/outfit-latin.woff2", weight: "100 900", style: "normal" },
|
||||
{ path: "../../public/fonts/outfit-latin-ext.woff2", weight: "100 900", style: "normal" },
|
||||
],
|
||||
display: "swap",
|
||||
variable: "--font-outfit",
|
||||
fallback: ["Inter", "Segoe UI", "system-ui", "sans-serif"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -75,6 +87,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
<ServiceWorkerRegistration />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
|
||||
|
||||
export const metadata: Metadata = { title: "Keine Verbindung" };
|
||||
|
||||
/**
|
||||
* Rückfallseite des Service Workers.
|
||||
*
|
||||
* Bewusst ohne Datenzugriff: sie muss auch dann funktionieren, wenn der
|
||||
* Server nicht erreichbar ist, und wird deshalb statisch vorgerendert.
|
||||
*/
|
||||
export default function OfflinePage() {
|
||||
return (
|
||||
<main
|
||||
id="inhalt"
|
||||
className="flex min-h-dvh flex-col items-center justify-center px-6 text-center"
|
||||
>
|
||||
<FlimmerMark className="h-16 w-16" />
|
||||
<h1 className="mt-6 text-2xl font-semibold tracking-tight">Gerade keine Verbindung</h1>
|
||||
<p className="mt-2 max-w-sm text-[var(--fl-muted)]">
|
||||
Flimmer erreicht deinen Server nicht. Bereits geladene Seiten und Poster bleiben verfügbar —
|
||||
alles Weitere kommt zurück, sobald die Verbindung wieder steht.
|
||||
</p>
|
||||
<Link href="/" className="fl-btn fl-btn-primary mt-6">
|
||||
Noch einmal versuchen
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,318 @@
|
||||
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";
|
||||
import { AchievementBadge } from "@/components/AchievementBadge";
|
||||
import { GRUPPEN, ensureAchievementsEvaluated, listAchievements } from "@/lib/achievements";
|
||||
|
||||
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),
|
||||
]);
|
||||
|
||||
// Beim allerersten Aufruf einmal auswerten: sonst bliebe die Liste leer,
|
||||
// bis nachts der Hintergrundlauf kommt — und wer sie öffnet, will sehen,
|
||||
// was er hat, nicht auf morgen vertröstet werden. Der Lauf ist stumm und
|
||||
// passiert genau einmal je Konto.
|
||||
await ensureAchievementsEvaluated(user.id);
|
||||
const abzeichen = await listAchievements(user.id);
|
||||
|
||||
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>
|
||||
|
||||
<section className="fl-card mt-6 p-5" aria-labelledby="errungenschaften">
|
||||
<h2 id="errungenschaften" className="text-lg font-semibold">
|
||||
Errungenschaften
|
||||
</h2>
|
||||
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
|
||||
{abzeichen.filter((eintrag) => eintrag.unlockedAt).length} von {abzeichen.length}{" "}
|
||||
erreicht. Was hier steht, bleibt stehen — auch wenn du eine Sichtung zurücknimmst.
|
||||
</p>
|
||||
|
||||
<div className="space-y-5">
|
||||
{GRUPPEN.map((gruppe) => {
|
||||
const eintraege = abzeichen.filter((eintrag) => eintrag.gruppe === gruppe.key);
|
||||
if (eintraege.length === 0) return null;
|
||||
|
||||
// Erreichte zuerst — man will sehen, was man hat, nicht was fehlt.
|
||||
const sortiert = [...eintraege].sort((a, b) => {
|
||||
if (Boolean(a.unlockedAt) !== Boolean(b.unlockedAt)) return a.unlockedAt ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, "de-DE");
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={gruppe.key}>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
{gruppe.label}
|
||||
</h3>
|
||||
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sortiert.map((eintrag) => (
|
||||
<AchievementBadge
|
||||
key={eintrag.key}
|
||||
name={eintrag.name}
|
||||
beschreibung={eintrag.beschreibung}
|
||||
symbol={eintrag.symbol}
|
||||
unlockedAt={eintrag.unlockedAt?.toISOString() ?? null}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "
|
||||
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" };
|
||||
|
||||
@@ -54,6 +55,7 @@ export default async function SearchPage({
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SearchPendingProvider>
|
||||
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
||||
|
||||
<p className="mt-3 text-sm text-[var(--fl-faint)]">
|
||||
@@ -63,11 +65,12 @@ export default async function SearchPage({
|
||||
</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.
|
||||
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>
|
||||
@@ -75,6 +78,8 @@ export default async function SearchPage({
|
||||
<Results query={query} filter={filter} />
|
||||
)}
|
||||
</div>
|
||||
</SearchResults>
|
||||
</SearchPendingProvider>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +124,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
|
||||
>
|
||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="line-clamp-2 text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
|
||||
<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)]">
|
||||
|
||||
@@ -120,6 +120,19 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
{year ? ` · ${year}` : ""}
|
||||
{title.status ? ` · ${title.status}` : ""}
|
||||
</p>
|
||||
{entry?.owned && (
|
||||
<span
|
||||
className="rounded-full px-2.5 py-0.5 text-xs font-medium text-[var(--fl-on-accent)]"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
title={
|
||||
entry.ownedSource
|
||||
? `In deiner Sammlung: ${entry.ownedSource}`
|
||||
: "In deiner Sammlung"
|
||||
}
|
||||
>
|
||||
Hab ich{entry.ownedSource ? ` · ${entry.ownedSource}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{entry && (
|
||||
<span
|
||||
className="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
@@ -225,6 +238,7 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
|
||||
) : (
|
||||
<div className="fl-card p-4">
|
||||
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
</div>
|
||||
)}
|
||||
@@ -245,6 +259,8 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
whereToWatch={entry.whereToWatch}
|
||||
tags={entry.tags.map((link) => link.tag.name)}
|
||||
suggestion={suggestion}
|
||||
owned={entry.owned}
|
||||
ownedSource={entry.ownedSource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -253,6 +269,7 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Wo läuft das?
|
||||
</h2>
|
||||
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
</div>
|
||||
)}
|
||||
@@ -299,6 +316,34 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinweis auf die eigene Sammlung — steht bewusst **vor** der
|
||||
* Streaming-Verfügbarkeit: was man hat, ist die verlässlichere Antwort auf
|
||||
* „wo kann ich das schauen".
|
||||
*/
|
||||
function OwnedNotice({ owned, source }: { owned: boolean; source: string | null }) {
|
||||
if (!owned) return null;
|
||||
|
||||
return (
|
||||
<p
|
||||
className="mb-3 flex items-center gap-2 rounded-xl border px-3 py-2 text-sm"
|
||||
style={{
|
||||
borderColor: "color-mix(in oklab, var(--fl-success) 45%, transparent)",
|
||||
backgroundColor: "color-mix(in oklab, var(--fl-success) 10%, transparent)",
|
||||
color: "var(--fl-success)",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
|
||||
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>
|
||||
In deiner Sammlung
|
||||
{source ? <span className="font-medium"> · {source}</span> : null}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listUnlockedAchievements } from "@/lib/achievements";
|
||||
import { AchievementSymbol } from "@/components/AchievementBadge";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
|
||||
import { formatMinutes, formatRelative } from "@/lib/labels";
|
||||
import { FlimmerLogo } from "@/components/brand/FlimmerLogo";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
|
||||
/**
|
||||
* Öffentliche Profilseite.
|
||||
*
|
||||
* Zeigt Zahlen und zuletzt Gesehenes — **keine** Notizen, keine Watchlist,
|
||||
* keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat. Die Seite ist
|
||||
* nur erreichbar, wenn der Nutzer sie freigeschaltet hat *und* der Betreiber
|
||||
* öffentliche Profile instanzweit erlaubt.
|
||||
*/
|
||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { profileSlug: slug, publicProfile: true, isActive: true },
|
||||
select: { name: true },
|
||||
});
|
||||
return user ? { title: `${user.name} · Profil` } : { title: "Profil" };
|
||||
}
|
||||
|
||||
export default async function PublicProfilePage({ params }: { params: Params }) {
|
||||
const { slug } = await params;
|
||||
const settings = await getSettings();
|
||||
if (!settings.publicProfilesEnabled) notFound();
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { profileSlug: slug, publicProfile: true, isActive: true },
|
||||
select: { id: true, name: true, createdAt: true, showAchievements: true },
|
||||
});
|
||||
if (!user) notFound();
|
||||
|
||||
const abzeichen = user.showAchievements ? await listUnlockedAchievements(user.id) : [];
|
||||
|
||||
const [totals, mostWatched, topRated, recent] = await Promise.all([
|
||||
getTotals(user.id),
|
||||
getMostWatched(user.id, 6),
|
||||
getTopRated(user.id, 6),
|
||||
prisma.watch.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { watchedAt: "desc" },
|
||||
take: 8,
|
||||
distinct: ["titleId"],
|
||||
include: {
|
||||
title: { select: { id: true, title: true, posterPath: true, type: true } },
|
||||
episode: { select: { seasonNumber: true, episodeNumber: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-4 sm:px-6">
|
||||
<Link href="/" aria-label="Flimmer">
|
||||
<FlimmerLogo />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<main id="inhalt" className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
|
||||
<header className="fl-card relative overflow-hidden p-6 sm:p-8">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-20 -top-28 h-64 w-64 rounded-full opacity-25 blur-3xl"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Öffentliches Profil
|
||||
</p>
|
||||
<h1 className="mt-1 text-3xl font-semibold tracking-tight">{user.name}</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Dabei seit {user.createdAt.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||
</p>
|
||||
|
||||
<dl className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Stat label="Schauzeit" value={formatMinutes(totals.minutes)} />
|
||||
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
|
||||
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
|
||||
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
|
||||
</dl>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{abzeichen.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">Errungenschaften</h2>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{abzeichen.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.key}
|
||||
title={eintrag.beschreibung}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[color-mix(in_oklab,var(--fl-accent)_35%,transparent)] bg-[color-mix(in_oklab,var(--fl-accent)_8%,transparent)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
<AchievementSymbol symbol={eintrag.symbol} className="h-4 w-4 text-[var(--fl-accent)]" />
|
||||
{eintrag.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{recent.length > 0 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
|
||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
|
||||
{recent.map((watch) => (
|
||||
<li key={watch.id}>
|
||||
<Poster path={watch.title.posterPath} alt={watch.title.title} size="w185" />
|
||||
<p className="mt-1.5 line-clamp-2 text-xs font-medium leading-snug">
|
||||
{watch.title.title}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{formatRelative(watch.watchedAt)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
<TitleList heading="Die meiste Zeit" items={mostWatched} showMinutes />
|
||||
<TitleList heading="Bestbewertet" items={topRated} />
|
||||
</div>
|
||||
|
||||
{totals.minutes === 0 && (
|
||||
<p className="fl-card mt-6 p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||
Hier ist noch nichts zu sehen.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 text-center text-xs text-[var(--fl-faint)] sm:px-6">
|
||||
Erstellt mit Flimmer · Metadaten von TMDB
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleList({
|
||||
heading,
|
||||
items,
|
||||
showMinutes = false,
|
||||
}: {
|
||||
heading: string;
|
||||
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number }[];
|
||||
showMinutes?: boolean;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">{heading}</h2>
|
||||
<ol className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.titleId} className="flex items-center gap-3">
|
||||
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="w-10 shrink-0">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title}</span>
|
||||
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
|
||||
{showMinutes ? formatMinutes(item.minutes) : item.rating ? `${item.rating}/10` : "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Metadata } from "next";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { AdminSettings, UserRow } from "@/components/settings/Forms";
|
||||
import { formatRelative } from "@/lib/labels";
|
||||
|
||||
export const metadata: Metadata = { title: "Verwaltung" };
|
||||
|
||||
export default async function AdminPage() {
|
||||
const admin = await requireAdmin();
|
||||
const settings = await getSettings();
|
||||
|
||||
const [users, lastRun, catalog] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
_count: { select: { entries: true } },
|
||||
},
|
||||
}),
|
||||
prisma.syncRun.findFirst({ orderBy: { startedAt: "desc" } }),
|
||||
Promise.all([
|
||||
prisma.title.count(),
|
||||
prisma.episode.count(),
|
||||
prisma.watch.count(),
|
||||
]),
|
||||
]);
|
||||
|
||||
const [titleCount, episodeCount, watchCount] = catalog;
|
||||
|
||||
return (
|
||||
<AppShell user={admin} instanceName={settings.instanceName}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verwaltung</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Einstellungen der Instanz, Konten und Hintergrundlauf.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Instanz</h2>
|
||||
<AdminSettings
|
||||
registrationEnabled={settings.registrationEnabled}
|
||||
publicProfilesEnabled={settings.publicProfilesEnabled}
|
||||
instanceName={settings.instanceName}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-4 text-lg font-semibold">Zustand</h2>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<Fact label="Konten" value={String(users.length)} />
|
||||
<Fact label="Titel im Katalog" value={titleCount.toLocaleString("de-DE")} />
|
||||
<Fact label="Episoden" value={episodeCount.toLocaleString("de-DE")} />
|
||||
<Fact label="Sichtungen" value={watchCount.toLocaleString("de-DE")} />
|
||||
<Fact
|
||||
label="TMDB"
|
||||
value={env.tmdb.isConfigured ? "eingerichtet" : "kein Schlüssel"}
|
||||
/>
|
||||
<Fact label="SMTP" value={env.smtp.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
||||
<Fact
|
||||
label="Zeitplan"
|
||||
value={
|
||||
env.scheduler.enabled
|
||||
? `täglich ${String(env.scheduler.hour).padStart(2, "0")}:05 Uhr`
|
||||
: "abgeschaltet"
|
||||
}
|
||||
/>
|
||||
<Fact label="OIDC" value={env.oidc.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5">
|
||||
<h2 className="mb-3 text-lg font-semibold">Letzter Abgleich</h2>
|
||||
{lastRun ? (
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
{formatRelative(lastRun.startedAt)} ·{" "}
|
||||
<span style={{ color: lastRun.ok ? "var(--fl-success)" : "var(--fl-danger)" }}>
|
||||
{lastRun.ok ? "erfolgreich" : "mit Fehlern"}
|
||||
</span>
|
||||
{lastRun.message ? ` · ${lastRun.message}` : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--fl-faint)]">Noch kein Abgleich gelaufen.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="fl-card mt-6 p-5">
|
||||
<h2 className="mb-2 text-lg font-semibold">Konten</h2>
|
||||
<ul className="divide-y divide-[var(--fl-border)]">
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
entries: user._count.entries,
|
||||
}}
|
||||
isSelf={user.id === admin.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||
<dd className="mt-0.5 font-medium">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { SymbolName } from "@/lib/achievements";
|
||||
|
||||
/**
|
||||
* Abzeichen mit Symbol.
|
||||
*
|
||||
* Die Symbole sind eigene SVG statt Emoji: Emoji sehen auf jedem System
|
||||
* anders aus, lassen sich nicht einfärben und passen nicht zur Formsprache
|
||||
* des Logos. Alle liegen auf demselben 24er-Raster, zeichnen nur mit
|
||||
* `currentColor` und tragen keine Farbe für sich — die kommt vom Zustand.
|
||||
*
|
||||
* Erreicht und offen unterscheiden sich **nicht nur durch Farbe**: das offene
|
||||
* Abzeichen ist blass *und* trägt den Hinweis „noch offen" im Text, das
|
||||
* erreichte nennt sein Datum. Farbe allein wäre für Screenreader und bei
|
||||
* Farbsehschwäche nichts wert.
|
||||
*/
|
||||
|
||||
const PFADE: Record<SymbolName, React.ReactNode> = {
|
||||
spross: (
|
||||
<>
|
||||
<path d="M12 21v-8" />
|
||||
<path d="M12 13c0-3 2-5 5-5 0 3-2 5-5 5Z" />
|
||||
<path d="M12 16c0-2.5-1.8-4.5-4.5-4.5 0 2.5 1.8 4.5 4.5 4.5Z" />
|
||||
</>
|
||||
),
|
||||
haken: <path d="m4 12.5 5 5L20 6.5" />,
|
||||
schluessel: (
|
||||
<>
|
||||
<circle cx="8" cy="12" r="3.5" />
|
||||
<path d="M11.5 12H21m-3 0v3.5m-3-3.5v2.5" />
|
||||
</>
|
||||
),
|
||||
haus: <path d="M3.5 11 12 4l8.5 7M6 9.5V20h12V9.5M10 20v-5.5h4V20" />,
|
||||
klappe: (
|
||||
<>
|
||||
<rect x="3" y="9" width="18" height="11" rx="2" />
|
||||
<path d="m3.5 9 3.5-4.5 3.5 4.5m0 0L14 4.5 17.5 9" />
|
||||
</>
|
||||
),
|
||||
planet: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<path d="M3.5 14.5c5 2.5 12 2.5 17 0" />
|
||||
</>
|
||||
),
|
||||
kompass: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="m15.5 8.5-2 5.5-5.5 2 2-5.5Z" />
|
||||
</>
|
||||
),
|
||||
bildschirm: (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="12" rx="2" />
|
||||
<path d="M8 21h8m-4-4v4" />
|
||||
</>
|
||||
),
|
||||
filmrolle: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<circle cx="12" cy="8.5" r="1.6" />
|
||||
<circle cx="15.4" cy="13.8" r="1.6" />
|
||||
<circle cx="8.6" cy="13.8" r="1.6" />
|
||||
</>
|
||||
),
|
||||
sonne: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6l1.4 1.4m10 10 1.4 1.4m0-12.8-1.4 1.4m-10 10-1.4 1.4" />
|
||||
</>
|
||||
),
|
||||
kalender: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M3.5 10h17M8 3v4m8-4v4" />
|
||||
</>
|
||||
),
|
||||
laeufer: (
|
||||
<>
|
||||
<circle cx="14" cy="5" r="2" />
|
||||
<path d="m8 21 3-6 3 2 1 5M11 15l-2-4 4-3 3 3 3 .5" />
|
||||
</>
|
||||
),
|
||||
berg: <path d="m2.5 19 6-11 4 6.5 2.5-4 6.5 8.5Z" />,
|
||||
herz: <path d="M12 20s-7.5-4.6-7.5-9.4A4.1 4.1 0 0 1 12 8a4.1 4.1 0 0 1 7.5 2.6C19.5 15.4 12 20 12 20Z" />,
|
||||
kreis: (
|
||||
<>
|
||||
<path d="M20 12a8 8 0 1 1-3.2-6.4" />
|
||||
<path d="M20 4v5h-5" />
|
||||
</>
|
||||
),
|
||||
maske: (
|
||||
<>
|
||||
<path d="M4 6h7v6a3.5 3.5 0 0 1-7 0Z" />
|
||||
<path d="M13 6h7v6a3.5 3.5 0 0 1-7 0Z" />
|
||||
<path d="M6.5 9h2m7 0h2" />
|
||||
</>
|
||||
),
|
||||
sanduhr: (
|
||||
<>
|
||||
<path d="M7 3h10M7 21h10" />
|
||||
<path d="M8 3v3.5L12 12l4-5.5V3M8 21v-3.5L12 12l4 5.5V21" />
|
||||
</>
|
||||
),
|
||||
stern: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.7l5.9-.8Z" />,
|
||||
salz: (
|
||||
<>
|
||||
<path d="M8.5 9h7l1 11h-9Z" />
|
||||
<path d="M9.5 9V5.5h5V9M11 3h2" />
|
||||
</>
|
||||
),
|
||||
scheibe: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<circle cx="12" cy="12" r="2.5" />
|
||||
</>
|
||||
),
|
||||
besen: (
|
||||
<>
|
||||
<path d="M14 3 8.5 12.5" />
|
||||
<path d="m5 14 6-3.5 4 6.5-6 3.5Z" />
|
||||
<path d="m9 20.5 2.5 0.5m2-2 2.5.5m-.5-3.5 2.5.5" />
|
||||
</>
|
||||
),
|
||||
mond: <path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z" />,
|
||||
spinne: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 9V5m0 10v4m-3-7H5m14 0h-4M9.5 9.5 6.5 6.5m8 3 3-3m-8 8-3 3m8-3 3 3" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export function AchievementSymbol({
|
||||
symbol,
|
||||
className,
|
||||
}: {
|
||||
symbol: SymbolName;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={clsx("h-6 w-6", className)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
{PFADE[symbol]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AchievementBadge({
|
||||
name,
|
||||
beschreibung,
|
||||
symbol,
|
||||
unlockedAt,
|
||||
}: {
|
||||
name: string;
|
||||
beschreibung: string;
|
||||
symbol: SymbolName;
|
||||
unlockedAt: string | null;
|
||||
}) {
|
||||
const erreicht = unlockedAt !== null;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clsx(
|
||||
"flex items-start gap-3 rounded-xl border p-3 transition-colors",
|
||||
erreicht
|
||||
? "border-[color-mix(in_oklab,var(--fl-accent)_35%,transparent)] bg-[color-mix(in_oklab,var(--fl-accent)_7%,transparent)]"
|
||||
: "border-[var(--fl-border)]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx("mt-0.5 shrink-0", erreicht ? "text-[var(--fl-accent)]" : "text-[var(--fl-faint)]")}
|
||||
style={erreicht ? { filter: "drop-shadow(0 0 6px color-mix(in oklab, var(--fl-accent) 40%, transparent))" } : undefined}
|
||||
>
|
||||
<AchievementSymbol symbol={symbol} />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className={clsx("font-medium", !erreicht && "text-[var(--fl-muted)]")}>{name}</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">{beschreibung}</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
|
||||
{erreicht ? `erreicht am ${formatiere(unlockedAt)}` : "noch offen"}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function formatiere(wert: string): string {
|
||||
return new Date(wert).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Link from "next/link";
|
||||
import { clsx } from "clsx";
|
||||
import { episodeCode } from "@/lib/labels";
|
||||
import type { UpcomingEpisode } from "@/lib/stats";
|
||||
|
||||
/**
|
||||
* Monatsraster mit den Ausstrahlungsterminen.
|
||||
*
|
||||
* Bewusst als Tabelle ausgezeichnet: ein Kalender *ist* eine Tabelle, und mit
|
||||
* Screenreader wird daraus dann „Woche 3, Donnerstag, 14." statt einer
|
||||
* ununterscheidbaren Kachelwüste.
|
||||
*/
|
||||
|
||||
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
function startOfGrid(month: Date): Date {
|
||||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
// Montag als Wochenanfang: Sonntag (0) wird zu 6.
|
||||
const weekday = (first.getDay() + 6) % 7;
|
||||
return new Date(first.getFullYear(), first.getMonth(), 1 - weekday);
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarMonth({
|
||||
month,
|
||||
episodes,
|
||||
}: {
|
||||
month: Date;
|
||||
episodes: UpcomingEpisode[];
|
||||
}) {
|
||||
const today = new Date();
|
||||
const start = startOfGrid(month);
|
||||
|
||||
// Sechs Wochen zeigen immer den ganzen Monat, egal auf welchen Tag er fällt.
|
||||
const days = Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + index);
|
||||
return date;
|
||||
});
|
||||
|
||||
const byDay = new Map<string, UpcomingEpisode[]>();
|
||||
for (const episode of episodes) {
|
||||
const key = episode.airDate.toDateString();
|
||||
const list = byDay.get(key);
|
||||
if (list) list.push(episode);
|
||||
else byDay.set(key, [episode]);
|
||||
}
|
||||
|
||||
const weeks = Array.from({ length: 6 }, (_, index) => days.slice(index * 7, index * 7 + 7));
|
||||
|
||||
return (
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<caption className="sr-only">
|
||||
Ausstrahlungstermine im {month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
{WEEKDAYS.map((day) => (
|
||||
<th
|
||||
key={day}
|
||||
scope="col"
|
||||
className="pb-2 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]"
|
||||
>
|
||||
{day}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<tr key={weekIndex}>
|
||||
{week.map((date) => {
|
||||
const inMonth = date.getMonth() === month.getMonth();
|
||||
const isToday = isSameDay(date, today);
|
||||
const list = byDay.get(date.toDateString()) ?? [];
|
||||
|
||||
return (
|
||||
<td
|
||||
key={date.toISOString()}
|
||||
className={clsx(
|
||||
"h-24 border border-[var(--fl-border)] p-1 align-top sm:h-28",
|
||||
!inMonth && "opacity-40",
|
||||
)}
|
||||
style={
|
||||
list.length > 0
|
||||
? { backgroundColor: "color-mix(in oklab, var(--fl-magenta) 7%, transparent)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"mb-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs tabular-nums",
|
||||
isToday
|
||||
? "font-semibold text-[var(--fl-on-accent)]"
|
||||
: "text-[var(--fl-faint)]",
|
||||
)}
|
||||
style={isToday ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
|
||||
<ul className="space-y-0.5">
|
||||
{list.slice(0, 3).map((episode) => (
|
||||
<li key={episode.episodeId}>
|
||||
<Link
|
||||
href={`/titel/${episode.titleId}`}
|
||||
title={`${episode.title} — ${episodeCode(episode.seasonNumber, episode.episodeNumber)} ${episode.name}`}
|
||||
className="block truncate rounded px-1 py-0.5 text-[0.7rem] leading-tight text-[var(--fl-text)] transition-colors hover:bg-[var(--fl-surface-2)]"
|
||||
>
|
||||
<span className="font-mono text-[var(--fl-accent)]">
|
||||
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
|
||||
</span>{" "}
|
||||
{episode.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{list.length > 3 && (
|
||||
<li className="px-1 text-[0.7rem] text-[var(--fl-faint)]">
|
||||
+ {list.length - 3} weitere
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
|
||||
|
||||
/**
|
||||
* Suchfeld mit Typfilter.
|
||||
@@ -26,6 +27,11 @@ export function SearchField({
|
||||
const [pending, startTransition] = useTransition();
|
||||
const first = useRef(true);
|
||||
|
||||
const reportPending = useReportSearchPending();
|
||||
useEffect(() => {
|
||||
reportPending(pending);
|
||||
}, [pending, reportPending]);
|
||||
|
||||
useEffect(() => {
|
||||
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
|
||||
// gerade geladene URL durch eine identische.
|
||||
@@ -41,7 +47,7 @@ export function SearchField({
|
||||
startTransition(() => {
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
}, 350);
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, filter, pathname, router]);
|
||||
@@ -68,14 +74,7 @@ export function SearchField({
|
||||
autoFocus
|
||||
className="fl-input pl-10 text-base"
|
||||
/>
|
||||
{pending && (
|
||||
<span
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
|
||||
role="status"
|
||||
>
|
||||
sucht …
|
||||
</span>
|
||||
)}
|
||||
<SearchSpinner show={pending} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/**
|
||||
* Rückmeldung beim Tippen — geteilt von der Suche und der Bibliothek.
|
||||
*
|
||||
* Beide Filterleisten wissen über `useTransition()` längst, dass eine neue
|
||||
* Antwort unterwegs ist. Die Trefferliste weiß es nicht: sie ist eine
|
||||
* Server-Komponente und steht als `children` daneben. Diese schmale Klammer
|
||||
* reicht das eine Bit vom Filter zur Liste durch.
|
||||
*
|
||||
* Bewusst zwei Kontexte: der Setter behält so über alle Renderdurchgänge
|
||||
* dieselbe Identität und taugt als Abhängigkeit eines Effekts.
|
||||
*/
|
||||
|
||||
const PendingContext = createContext(false);
|
||||
const SetPendingContext = createContext<(value: boolean) => void>(() => {});
|
||||
|
||||
export function SearchPendingProvider({ children }: { children: ReactNode }) {
|
||||
const [pending, setPending] = useState(false);
|
||||
return (
|
||||
<SetPendingContext.Provider value={setPending}>
|
||||
<PendingContext.Provider value={pending}>{children}</PendingContext.Provider>
|
||||
</SetPendingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Für die Filterleiste: meldet, ob gerade gesucht wird. */
|
||||
export function useReportSearchPending() {
|
||||
return useContext(SetPendingContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dämpft die Treffer, solange die neue Antwort unterwegs ist.
|
||||
*
|
||||
* Die alten Treffer bleiben stehen — sie verschwinden zu lassen wäre ein
|
||||
* Rückschritt, weil die Liste dann bei jedem Tastendruck springt. Gedämpft
|
||||
* und nicht anklickbar ist aber sichtbar „nicht mehr aktuell".
|
||||
*/
|
||||
export function SearchResults({ children }: { children: ReactNode }) {
|
||||
const pending = useContext(PendingContext);
|
||||
return (
|
||||
<div
|
||||
aria-busy={pending}
|
||||
className={clsx(
|
||||
"transition-opacity duration-200 motion-reduce:transition-none",
|
||||
pending && "pointer-events-none opacity-45",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kleiner Hinweis neben dem Eingabefeld, solange gesucht wird. */
|
||||
export function SearchSpinner({ show }: { show: boolean }) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<span
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
|
||||
role="status"
|
||||
>
|
||||
sucht …
|
||||
</span>
|
||||
);
|
||||
}
|
||||