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 |
@@ -4,6 +4,7 @@ node_modules
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
data
|
data
|
||||||
|
backups
|
||||||
docs/screenshots
|
docs/screenshots
|
||||||
*.log
|
*.log
|
||||||
test-results
|
test-results
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ AUTH_SECRET=bitte-durch-zufallswert-ersetzen
|
|||||||
# Ablage für Bild-Cache und sonstige Laufzeitdaten.
|
# Ablage für Bild-Cache und sonstige Laufzeitdaten.
|
||||||
DATA_DIR=/app/data
|
DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Port, auf dem Flimmer auf dem Host lauscht.
|
||||||
|
APP_PORT=3000
|
||||||
|
|
||||||
# Zeitzone für Kalender, Statistiken und geplante Läufe
|
# Zeitzone für Kalender, Statistiken und geplante Läufe
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
@@ -68,6 +71,13 @@ SMTP_PASSWORD=
|
|||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_FROM=Flimmer <flimmer@example.com>
|
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) ---------------------------------------------------
|
# --- OIDC / SSO (optional) ---------------------------------------------------
|
||||||
# Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert.
|
# Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert.
|
||||||
# Redirect-URI beim Anbieter eintragen: <APP_URL>/api/auth/oidc/callback
|
# Redirect-URI beim Anbieter eintragen: <APP_URL>/api/auth/oidc/callback
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Produktion / Laufzeit
|
# Produktion / Laufzeit
|
||||||
data/
|
data/
|
||||||
|
backups/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Umgebung — niemals committen!
|
# Umgebung — niemals committen!
|
||||||
|
|||||||
@@ -7,6 +7,240 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [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
|
## [0.7.0] — 2026-09-01
|
||||||
|
|
||||||
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
|
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
|
||||||
@@ -321,7 +555,8 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
|
|||||||
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
||||||
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
||||||
|
|
||||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.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.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.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
|
||||||
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
|
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
|||||||
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
|
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
|
||||||
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
|
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
|
||||||
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`.
|
- **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.
|
- Next hängt selbst einen Block an diese Datei an — der bleibt drin.
|
||||||
|
|
||||||
## Fortschritt
|
## Fortschritt
|
||||||
@@ -109,6 +112,15 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
|||||||
|
|
||||||
**Alle sechs Etappen abgeschlossen. Gesamtzeit: 2 h 16 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
|
### Nützliches für spätere Arbeiten
|
||||||
- Suche ohne TMDB-Schlüssel ausprobieren:
|
- Suche ohne TMDB-Schlüssel ausprobieren:
|
||||||
`npm run tmdb:stub` und dann
|
`npm run tmdb:stub` und dann
|
||||||
@@ -119,6 +131,27 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
|||||||
das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok.
|
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
|
- Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das
|
||||||
Write-Werkzeug benutzen.
|
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 -->
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,84 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## Gesamtübersicht
|
||||||
|
|
||||||
| Etappe | Version | Dauer |
|
| Etappe | Version | Dauer |
|
||||||
@@ -114,8 +192,10 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
|||||||
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
|
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
|
||||||
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
|
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
|
||||||
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 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 |
|
||||||
|
|
||||||
**Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16)
|
**Gesamtentwicklungszeit: 5 h 01 min** (301 min, 2026-09-01 18:09 – 2026-09-02 11:13)
|
||||||
|
|
||||||
### Womit die Zeit verging
|
### Womit die Zeit verging
|
||||||
|
|
||||||
|
|||||||
@@ -114,14 +114,31 @@ flimmer.example.com {
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd flimmer
|
cd flimmer
|
||||||
git pull
|
./update.sh
|
||||||
docker compose build
|
|
||||||
docker compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Datenbankmigrationen laufen automatisch beim Start des Containers — ein eigener
|
Das Skript sichert zuerst die Datenbank, holt die Änderungen, baut das Image neu
|
||||||
Handgriff ist nicht nötig. **Vor** einem Update ein Backup zu ziehen ist
|
und wartet, bis die Instanz wieder antwortet. Schlägt etwas fehl, nennt es den
|
||||||
trotzdem eine gute Angewohnheit.
|
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
|
## Backup
|
||||||
|
|
||||||
@@ -161,6 +178,31 @@ mit Erklärungen steht in [`.env.example`](.env.example). Die wichtigsten:
|
|||||||
| `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen |
|
| `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen |
|
||||||
| `SMTP_*` | E-Mail-Versand |
|
| `SMTP_*` | E-Mail-Versand |
|
||||||
| `OIDC_*` | Optionale SSO-Anbindung (Authelia, Keycloak, Authentik …) |
|
| `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
|
## Entwicklung
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`.
|
# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`.
|
||||||
# docker compose -f docker-compose.dev.yml up -d
|
# 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:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
|
|||||||
@@ -237,6 +237,46 @@ 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
|
## Einstellungen
|
||||||
|
|
||||||

|

|
||||||
@@ -256,6 +296,40 @@ 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
|
## Benachrichtigungen
|
||||||
|
|
||||||

|

|
||||||
@@ -299,6 +373,89 @@ 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
|
## API-Schlüssel
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -116,6 +116,19 @@ curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/jso
|
|||||||
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
|
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
|
||||||
Markierung dieser Episoden im aktuellen Durchlauf.
|
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
|
### Suche
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -149,11 +162,24 @@ GET /api/v1/universen
|
|||||||
POST /api/v1/universen
|
POST /api/v1/universen
|
||||||
GET /api/v1/universen/{id}?reihenfolge={orderId}
|
GET /api/v1/universen/{id}?reihenfolge={orderId}
|
||||||
POST /api/v1/universen/{id}
|
POST /api/v1/universen/{id}
|
||||||
|
PATCH /api/v1/universen/{id}
|
||||||
```
|
```
|
||||||
|
|
||||||
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
|
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
|
||||||
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
|
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
|
||||||
Erscheinungsreihenfolge.
|
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
|
### Export
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "flimmer",
|
"name": "flimmer",
|
||||||
"version": "0.6.1",
|
"version": "0.7.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "flimmer",
|
"name": "flimmer",
|
||||||
"version": "0.6.1",
|
"version": "0.7.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.3",
|
||||||
"bcryptjs": "3.0.3",
|
"bcryptjs": "3.0.3",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"date-fns": "4.4.0",
|
"date-fns": "4.4.0",
|
||||||
@@ -536,6 +538,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@img/colour": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
@@ -1135,6 +1143,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.3.4",
|
"version": "16.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz",
|
||||||
@@ -1281,6 +1295,207 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@prisma/adapter-pg": {
|
||||||
"version": "7.10.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz",
|
||||||
@@ -1645,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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2585,6 +2825,20 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"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": {
|
"node_modules/aws-ssl-profiles": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||||
@@ -4070,6 +4324,24 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/rc9": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
|
||||||
@@ -4116,6 +4388,12 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/remeda": {
|
||||||
"version": "2.33.4",
|
"version": "2.33.4",
|
||||||
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
||||||
@@ -4429,6 +4707,24 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flimmer",
|
"name": "flimmer",
|
||||||
"version": "0.7.0",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.3",
|
||||||
"bcryptjs": "3.0.3",
|
"bcryptjs": "3.0.3",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"date-fns": "4.4.0",
|
"date-fns": "4.4.0",
|
||||||
|
|||||||
@@ -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)
|
publicProfile Boolean @default(false)
|
||||||
profileSlug String? @unique
|
profileSlug String? @unique
|
||||||
|
|
||||||
|
/// Errungenschaften auf der öffentlichen Profilseite zeigen
|
||||||
|
showAchievements Boolean @default(true)
|
||||||
|
|
||||||
// Benachrichtigungswünsche
|
// Benachrichtigungswünsche
|
||||||
notifyNewEpisode Boolean @default(true)
|
notifyNewEpisode Boolean @default(true)
|
||||||
notifyRelease Boolean @default(true)
|
notifyRelease Boolean @default(true)
|
||||||
@@ -53,6 +56,9 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
oidcAccounts OidcAccount[]
|
oidcAccounts OidcAccount[]
|
||||||
|
passkeys Passkey[]
|
||||||
|
mediaServers MediaServerConnection[]
|
||||||
|
achievements Achievement[]
|
||||||
entries LibraryEntry[]
|
entries LibraryEntry[]
|
||||||
watches Watch[]
|
watches Watch[]
|
||||||
tags Tag[]
|
tags Tag[]
|
||||||
@@ -109,6 +115,37 @@ model OidcAccount {
|
|||||||
@@index([userId])
|
@@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 …)
|
/// Instanzweite Einstellungen (Registrierung offen/zu, Instanzname …)
|
||||||
model AppSetting {
|
model AppSetting {
|
||||||
key String @id
|
key String @id
|
||||||
@@ -175,6 +212,7 @@ model Title {
|
|||||||
watches Watch[]
|
watches Watch[]
|
||||||
providers TitleProvider[]
|
providers TitleProvider[]
|
||||||
universeItems UniverseItem[]
|
universeItems UniverseItem[]
|
||||||
|
serverItems MediaServerItem[]
|
||||||
|
|
||||||
@@unique([type, tmdbId])
|
@@unique([type, tmdbId])
|
||||||
@@index([type])
|
@@index([type])
|
||||||
@@ -219,6 +257,7 @@ model Episode {
|
|||||||
runtime Int?
|
runtime Int?
|
||||||
|
|
||||||
watches Watch[]
|
watches Watch[]
|
||||||
|
serverItems MediaServerItem[]
|
||||||
|
|
||||||
@@unique([titleId, seasonNumber, episodeNumber])
|
@@unique([titleId, seasonNumber, episodeNumber])
|
||||||
@@index([titleId])
|
@@index([titleId])
|
||||||
@@ -359,6 +398,11 @@ model Watch {
|
|||||||
run Int @default(0)
|
run Int @default(0)
|
||||||
watchedAt DateTime @default(now())
|
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
|
/// Tatsächlich angerechnete Minuten — friert die Laufzeit zum Zeitpunkt der
|
||||||
/// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen.
|
/// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen.
|
||||||
minutes Int @default(0)
|
minutes Int @default(0)
|
||||||
@@ -516,3 +560,99 @@ model SyncRun {
|
|||||||
|
|
||||||
@@index([kind, startedAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
|
||||||
const OUT = "docs/screenshots";
|
const OUT = "docs/screenshots";
|
||||||
const BASE = "http://localhost:3011";
|
const BASE = "http://localhost:3011";
|
||||||
@@ -32,6 +33,73 @@ async function login(page) {
|
|||||||
await Promise.all([page.waitForURL(`${BASE}/`), page.click("button[type=submit]")]);
|
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 = {}) {
|
async function shot(page, name, url, opts = {}) {
|
||||||
await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" });
|
await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" });
|
||||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
|
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
|
||||||
@@ -78,6 +146,74 @@ async function shot(page, name, url, opts = {}) {
|
|||||||
await ctx.close();
|
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 ---
|
// --- Hell ---
|
||||||
{
|
{
|
||||||
const { ctx, page } = await session(1400, 900, "light");
|
const { ctx, page } = await session(1400, 900, "light");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/lib/auth";
|
|||||||
import { getSettings, isRegistrationOpen } from "@/lib/settings";
|
import { getSettings, isRegistrationOpen } from "@/lib/settings";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import { AuthSwitch, LoginForm } from "@/components/AuthForm";
|
import { AuthSwitch, LoginForm } from "@/components/AuthForm";
|
||||||
|
import { PasskeyLoginButton } from "@/components/settings/Passkeys";
|
||||||
import { loginAction } from "../actions";
|
import { loginAction } from "../actions";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Anmelden" };
|
export const metadata: Metadata = { title: "Anmelden" };
|
||||||
@@ -36,16 +37,22 @@ export default async function LoginPage({
|
|||||||
|
|
||||||
<LoginForm action={loginAction} weiter={params.weiter} />
|
<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)]">
|
<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)]" />
|
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
||||||
oder
|
oder
|
||||||
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
<span className="h-px flex-1 bg-[var(--fl-border)]" />
|
||||||
</div>
|
</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}
|
{env.oidc.buttonLabel}
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import {
|
|||||||
startRewatch,
|
startRewatch,
|
||||||
undoWatches,
|
undoWatches,
|
||||||
unmarkEpisodes,
|
unmarkEpisodes,
|
||||||
|
unmarkMovie,
|
||||||
unmarkSeason,
|
unmarkSeason,
|
||||||
updateEntryDetails,
|
updateEntryDetails,
|
||||||
watchNextEpisode,
|
watchNextEpisode,
|
||||||
type WatchResult,
|
type WatchResult,
|
||||||
} from "@/lib/library";
|
} from "@/lib/library";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { achievementName, evaluateAchievements } from "@/lib/achievements";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-Aktionen rund um die Bibliothek.
|
* Server-Aktionen rund um die Bibliothek.
|
||||||
@@ -37,11 +39,14 @@ export type ActionResult = {
|
|||||||
message?: string;
|
message?: string;
|
||||||
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
|
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
|
||||||
watchIds?: string[];
|
watchIds?: string[];
|
||||||
|
/** Gerade erreichte Errungenschaften, fertig benannt. */
|
||||||
|
achievements?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
async function run(
|
async function run(
|
||||||
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
|
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
|
||||||
revalidate: string[] = [],
|
revalidate: string[] = [],
|
||||||
|
{ pruefeErrungenschaften = false }: { pruefeErrungenschaften?: boolean } = {},
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||||
@@ -49,7 +54,25 @@ async function run(
|
|||||||
try {
|
try {
|
||||||
const result = (await fn(user.id)) ?? {};
|
const result = (await fn(user.id)) ?? {};
|
||||||
for (const path of revalidate) revalidatePath(path, "layout");
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof LibraryError) return { ok: false, message: error.message };
|
if (error instanceof LibraryError) return { ok: false, message: error.message };
|
||||||
console.error("Bibliotheksaktion fehlgeschlagen:", error);
|
console.error("Bibliotheksaktion fehlgeschlagen:", error);
|
||||||
@@ -74,7 +97,7 @@ export async function addTitleAction(titleId: string, status: string = "WATCHLIS
|
|||||||
message:
|
message:
|
||||||
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
|
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
|
||||||
};
|
};
|
||||||
}, PATHS);
|
}, PATHS, { pruefeErrungenschaften: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function trashEntryAction(entryId: string) {
|
export async function trashEntryAction(entryId: string) {
|
||||||
@@ -129,7 +152,7 @@ export async function startRewatchAction(entryId: string) {
|
|||||||
return run(async (userId) => {
|
return run(async (userId) => {
|
||||||
const entry = await startRewatch(userId, cuid.parse(entryId));
|
const entry = await startRewatch(userId, cuid.parse(entryId));
|
||||||
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` };
|
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` };
|
||||||
}, PATHS);
|
}, PATHS, { pruefeErrungenschaften: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -147,7 +170,7 @@ export async function toggleEpisodeAction(entryId: string, episodeId: string, wa
|
|||||||
}
|
}
|
||||||
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
|
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
|
||||||
return { message: "Markierung entfernt" };
|
return { message: "Markierung entfernt" };
|
||||||
}, PATHS);
|
}, PATHS, { pruefeErrungenschaften: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
|
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
|
||||||
@@ -157,29 +180,37 @@ export async function toggleSeasonAction(entryId: string, seasonId: string, watc
|
|||||||
}
|
}
|
||||||
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
|
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
|
||||||
return { message: "Staffel zurückgesetzt" };
|
return { message: "Staffel zurückgesetzt" };
|
||||||
}, PATHS);
|
}, PATHS, { pruefeErrungenschaften: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markUpToAction(entryId: string, episodeId: string) {
|
export async function markUpToAction(entryId: string, episodeId: string) {
|
||||||
return run(
|
return run(
|
||||||
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
|
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
|
||||||
PATHS,
|
PATHS,
|
||||||
|
{ pruefeErrungenschaften: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function watchNextAction(entryId: string) {
|
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) {
|
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[]) {
|
export async function undoAction(entryId: string, watchIds: string[]) {
|
||||||
return run(async (userId) => {
|
return run(async (userId) => {
|
||||||
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
|
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
|
||||||
return { message: "Rückgängig gemacht" };
|
return { message: "Rückgängig gemacht" };
|
||||||
}, PATHS);
|
}, PATHS, { pruefeErrungenschaften: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -226,7 +257,7 @@ export async function setTagsAction(entryId: string, tagNames: string[]) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return { message: "Tags gespeichert" };
|
return { message: "Tags gespeichert" };
|
||||||
}, PATHS);
|
}, PATHS, { pruefeErrungenschaften: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ import { setSetting } from "@/lib/settings";
|
|||||||
import { sendTestNotification } from "@/lib/notify";
|
import { sendTestNotification } from "@/lib/notify";
|
||||||
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
|
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
|
||||||
import { runSync } from "@/lib/sync";
|
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. */
|
/** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */
|
||||||
|
|
||||||
@@ -56,6 +65,7 @@ const profileSchema = z.object({
|
|||||||
name: z.string().trim().min(1).max(80),
|
name: z.string().trim().min(1).max(80),
|
||||||
region: z.string().trim().length(2).toUpperCase(),
|
region: z.string().trim().length(2).toUpperCase(),
|
||||||
publicProfile: z.boolean(),
|
publicProfile: z.boolean(),
|
||||||
|
showAchievements: z.boolean(),
|
||||||
profileSlug: z
|
profileSlug: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -304,3 +314,148 @@ export async function runSyncAction() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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,
|
removeTitle,
|
||||||
renameOrder,
|
renameOrder,
|
||||||
renameUniverse,
|
renameUniverse,
|
||||||
|
setDefaultOrder,
|
||||||
setOrderPositions,
|
setOrderPositions,
|
||||||
shareUniverse,
|
shareUniverse,
|
||||||
trashUniverse,
|
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[]) {
|
export async function reorderAction(orderId: string, itemIds: string[]) {
|
||||||
return run(async (userId) => {
|
return run(async (userId) => {
|
||||||
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
|
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,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));
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
markUpTo,
|
markUpTo,
|
||||||
undoWatches,
|
undoWatches,
|
||||||
unmarkEpisodes,
|
unmarkEpisodes,
|
||||||
|
unmarkMovie,
|
||||||
watchNextEpisode,
|
watchNextEpisode,
|
||||||
} from "@/lib/library";
|
} from "@/lib/library";
|
||||||
|
|
||||||
@@ -75,9 +76,17 @@ export const POST = withApi(async (request, user, context) => {
|
|||||||
const deleteSchema = z.union([
|
const deleteSchema = z.union([
|
||||||
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
|
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({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||||
|
z.object({ action: z.literal("movie") }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */
|
/**
|
||||||
|
* 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) => {
|
export const DELETE = withApi(async (request, user, context) => {
|
||||||
const { entryId } = await context.params;
|
const { entryId } = await context.params;
|
||||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||||
@@ -86,8 +95,10 @@ export const DELETE = withApi(async (request, user, context) => {
|
|||||||
|
|
||||||
if ("watchIds" in body) {
|
if ("watchIds" in body) {
|
||||||
await undoWatches(user.id, entryId, body.watchIds);
|
await undoWatches(user.id, entryId, body.watchIds);
|
||||||
} else {
|
} else if ("episodeIds" in body) {
|
||||||
await unmarkEpisodes(user.id, entryId, body.episodeIds);
|
await unmarkEpisodes(user.id, entryId, body.episodeIds);
|
||||||
|
} else {
|
||||||
|
await unmarkMovie(user.id, entryId);
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ export function GET() {
|
|||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
summary: "Sichtungen zurücknehmen",
|
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: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
@@ -226,6 +231,11 @@ export function GET() {
|
|||||||
properties: { episodeIds: { type: "array", items: { type: "string" } } },
|
properties: { episodeIds: { type: "array", items: { type: "string" } } },
|
||||||
required: ["episodeIds"],
|
required: ["episodeIds"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { action: { const: "movie" } },
|
||||||
|
required: ["action"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -287,6 +297,9 @@ export function GET() {
|
|||||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||||
get: {
|
get: {
|
||||||
summary: "Universum mit Reihenfolge und Fortschritt",
|
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" } }],
|
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
|
||||||
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
|
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
|
||||||
},
|
},
|
||||||
@@ -306,6 +319,33 @@ export function GET() {
|
|||||||
},
|
},
|
||||||
responses: { "200": { description: "Aufgenommen" } },
|
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": {
|
"/export": {
|
||||||
get: {
|
get: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiError, readJson, withApi } from "@/lib/api";
|
import { apiError, readJson, withApi } from "@/lib/api";
|
||||||
import { addTitle, getUniverse } from "@/lib/universes";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { addTitle, getUniverse, setDefaultOrder } from "@/lib/universes";
|
||||||
|
|
||||||
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
|
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
|
||||||
export const GET = withApi(async (request, user, context) => {
|
export const GET = withApi(async (request, user, context) => {
|
||||||
@@ -25,3 +26,25 @@ export const POST = withApi(async (request, user, context) => {
|
|||||||
await addTitle(user.id, id, body.titleId);
|
await addTitle(user.id, id, body.titleId);
|
||||||
return { ok: true };
|
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 };
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type Libr
|
|||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { LibraryCard } from "@/components/library/LibraryCard";
|
import { LibraryCard } from "@/components/library/LibraryCard";
|
||||||
import { LibraryFilters } from "@/components/library/LibraryFilters";
|
import { LibraryFilters } from "@/components/library/LibraryFilters";
|
||||||
|
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
|
||||||
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
|
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Bibliothek" };
|
export const metadata: Metadata = { title: "Bibliothek" };
|
||||||
@@ -56,8 +57,10 @@ export default async function LibraryPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SearchPendingProvider>
|
||||||
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
||||||
|
|
||||||
|
<SearchResults>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="fl-card p-8 text-center">
|
<div className="fl-card p-8 text-center">
|
||||||
@@ -89,6 +92,8 @@ export default async function LibraryPage({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SearchResults>
|
||||||
|
</SearchPendingProvider>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { requireUser } from "@/lib/auth";
|
|||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { PasswordForm, ProfileForm } from "@/components/settings/Forms";
|
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";
|
import pkg from "../../../package.json";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Einstellungen" };
|
export const metadata: Metadata = { title: "Einstellungen" };
|
||||||
@@ -19,6 +24,7 @@ export default async function SettingsPage() {
|
|||||||
region: true,
|
region: true,
|
||||||
publicProfile: true,
|
publicProfile: true,
|
||||||
profileSlug: true,
|
profileSlug: true,
|
||||||
|
showAchievements: true,
|
||||||
notifyNewEpisode: true,
|
notifyNewEpisode: true,
|
||||||
notifyRelease: true,
|
notifyRelease: true,
|
||||||
notifyAvailability: true,
|
notifyAvailability: true,
|
||||||
@@ -28,6 +34,9 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
const { passwordHash, ...profile } = user;
|
const { passwordHash, ...profile } = user;
|
||||||
|
|
||||||
|
const passkeys = env.passkeys.enabled ? await listPasskeys(session.id) : [];
|
||||||
|
const medienserver = await listConnections(session.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="fl-card p-5">
|
<section className="fl-card p-5">
|
||||||
@@ -42,6 +51,57 @@ export default async function SettingsPage() {
|
|||||||
</section>
|
</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)]">
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
Flimmer {pkg.version} · Instanz {settings.instanceName}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { formatMinutes } from "@/lib/labels";
|
|||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { Poster } from "@/components/Poster";
|
import { Poster } from "@/components/Poster";
|
||||||
import { DistributionBars, MonthlyChart } from "@/components/stats/Charts";
|
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 const metadata: Metadata = { title: "Statistik" };
|
||||||
|
|
||||||
@@ -42,6 +44,13 @@ export default async function StatsPage({
|
|||||||
getYearReview(user.id, year),
|
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) {
|
if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) {
|
||||||
return (
|
return (
|
||||||
<AppShell user={user} instanceName={settings.instanceName}>
|
<AppShell user={user} instanceName={settings.instanceName}>
|
||||||
@@ -205,6 +214,48 @@ export default async function StatsPage({
|
|||||||
emptyText="Noch nichts bewertet."
|
emptyText="Noch nichts bewertet."
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "
|
|||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { Poster } from "@/components/Poster";
|
import { Poster } from "@/components/Poster";
|
||||||
import { SearchField } from "@/components/SearchField";
|
import { SearchField } from "@/components/SearchField";
|
||||||
|
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Suchen" };
|
export const metadata: Metadata = { title: "Suchen" };
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ export default async function SearchPage({
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SearchPendingProvider>
|
||||||
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
||||||
|
|
||||||
<p className="mt-3 text-sm text-[var(--fl-faint)]">
|
<p className="mt-3 text-sm text-[var(--fl-faint)]">
|
||||||
@@ -63,11 +65,12 @@ export default async function SearchPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<SearchResults>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
{!env.tmdb.isConfigured ? (
|
{!env.tmdb.isConfigured ? (
|
||||||
<Notice tone="warning">
|
<Notice tone="warning">
|
||||||
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "}
|
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in
|
||||||
<code>.env</code> ein und starte die Instanz neu.
|
der <code>.env</code> ein und starte die Instanz neu.
|
||||||
</Notice>
|
</Notice>
|
||||||
) : query.length < 2 ? (
|
) : query.length < 2 ? (
|
||||||
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
|
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
|
||||||
@@ -75,6 +78,8 @@ export default async function SearchPage({
|
|||||||
<Results query={query} filter={filter} />
|
<Results query={query} filter={filter} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SearchResults>
|
||||||
|
</SearchPendingProvider>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,7 +124,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
|
|||||||
>
|
>
|
||||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||||
<div className="mt-2 space-y-0.5">
|
<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}
|
{item.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-[var(--fl-faint)]">
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { listUnlockedAchievements } from "@/lib/achievements";
|
||||||
|
import { AchievementSymbol } from "@/components/AchievementBadge";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
|
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
|
||||||
import { formatMinutes, formatRelative } from "@/lib/labels";
|
import { formatMinutes, formatRelative } from "@/lib/labels";
|
||||||
@@ -35,10 +37,12 @@ export default async function PublicProfilePage({ params }: { params: Params })
|
|||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: { profileSlug: slug, publicProfile: true, isActive: true },
|
where: { profileSlug: slug, publicProfile: true, isActive: true },
|
||||||
select: { id: true, name: true, createdAt: true },
|
select: { id: true, name: true, createdAt: true, showAchievements: true },
|
||||||
});
|
});
|
||||||
if (!user) notFound();
|
if (!user) notFound();
|
||||||
|
|
||||||
|
const abzeichen = user.showAchievements ? await listUnlockedAchievements(user.id) : [];
|
||||||
|
|
||||||
const [totals, mostWatched, topRated, recent] = await Promise.all([
|
const [totals, mostWatched, topRated, recent] = await Promise.all([
|
||||||
getTotals(user.id),
|
getTotals(user.id),
|
||||||
getMostWatched(user.id, 6),
|
getMostWatched(user.id, 6),
|
||||||
@@ -89,6 +93,24 @@ export default async function PublicProfilePage({ params }: { params: Params })
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 && (
|
{recent.length > 0 && (
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
|
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef, useState, useTransition } from "react";
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suchfeld mit Typfilter.
|
* Suchfeld mit Typfilter.
|
||||||
@@ -26,6 +27,11 @@ export function SearchField({
|
|||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const first = useRef(true);
|
const first = useRef(true);
|
||||||
|
|
||||||
|
const reportPending = useReportSearchPending();
|
||||||
|
useEffect(() => {
|
||||||
|
reportPending(pending);
|
||||||
|
}, [pending, reportPending]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
|
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
|
||||||
// gerade geladene URL durch eine identische.
|
// gerade geladene URL durch eine identische.
|
||||||
@@ -41,7 +47,7 @@ export function SearchField({
|
|||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
});
|
});
|
||||||
}, 350);
|
}, 250);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [value, filter, pathname, router]);
|
}, [value, filter, pathname, router]);
|
||||||
@@ -68,14 +74,7 @@ export function SearchField({
|
|||||||
autoFocus
|
autoFocus
|
||||||
className="fl-input pl-10 text-base"
|
className="fl-input pl-10 text-base"
|
||||||
/>
|
/>
|
||||||
{pending && (
|
<SearchSpinner show={pending} />
|
||||||
<span
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
sucht …
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import type { ActionResult } from "@/app/actions/library";
|
|||||||
type Toast = {
|
type Toast = {
|
||||||
id: number;
|
id: number;
|
||||||
message: string;
|
message: string;
|
||||||
tone: "info" | "error";
|
tone: "info" | "error" | "erfolg";
|
||||||
undo?: () => Promise<void> | void;
|
undo?: () => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +58,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
show(result.message, {
|
show(result.message, {
|
||||||
undo: undo && ids.length > 0 ? () => void undo(ids) : undefined,
|
undo: undo && ids.length > 0 ? () => void undo(ids) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Errungenschaften bekommen eine eigene Einblendung — sie sollen nicht
|
||||||
|
// in der Meldung über das Abhaken untergehen.
|
||||||
|
for (const name of result.achievements ?? []) {
|
||||||
|
show(`Errungenschaft: ${name}`, { tone: "erfolg" });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[show],
|
[show],
|
||||||
);
|
);
|
||||||
@@ -96,8 +102,15 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
|||||||
borderColor:
|
borderColor:
|
||||||
toast.tone === "error"
|
toast.tone === "error"
|
||||||
? "color-mix(in oklab, var(--fl-danger) 50%, transparent)"
|
? "color-mix(in oklab, var(--fl-danger) 50%, transparent)"
|
||||||
|
: toast.tone === "erfolg"
|
||||||
|
? "color-mix(in oklab, var(--fl-success) 50%, transparent)"
|
||||||
: "var(--fl-border)",
|
: "var(--fl-border)",
|
||||||
color: toast.tone === "error" ? "var(--fl-danger)" : "var(--fl-text)",
|
color:
|
||||||
|
toast.tone === "error"
|
||||||
|
? "var(--fl-danger)"
|
||||||
|
: toast.tone === "erfolg"
|
||||||
|
? "var(--fl-success)"
|
||||||
|
: "var(--fl-text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex-1">{toast.message}</span>
|
<span className="flex-1">{toast.message}</span>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
<p className="line-clamp-2 text-sm font-medium leading-snug">{item.title}</p>
|
<p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug">{item.title}</p>
|
||||||
<p className="text-xs text-[var(--fl-faint)]">
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
{item.type === "SERIES" ? "Serie" : "Film"}
|
{item.type === "SERIES" ? "Serie" : "Film"}
|
||||||
{item.year ? ` · ${item.year}` : ""}
|
{item.year ? ` · ${item.year}` : ""}
|
||||||
@@ -74,8 +74,9 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
|||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{showProgress && (
|
|
||||||
<div className="pt-0.5">
|
<div className="pt-0.5">
|
||||||
|
{showProgress ? (
|
||||||
|
<>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
percent={item.percent}
|
percent={item.percent}
|
||||||
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
||||||
@@ -83,9 +84,19 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
|||||||
<p className="mt-1 text-xs text-[var(--fl-faint)]">
|
<p className="mt-1 text-xs text-[var(--fl-faint)]">
|
||||||
{item.watched}/{item.total} Episoden
|
{item.watched}/{item.total} Episoden
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Filme und Watchlist-Einträge haben keinen Balken. Der Platz wird
|
||||||
|
// trotzdem freigehalten — sonst fallen sie kürzer aus als Serien und
|
||||||
|
// das Raster franst reihenweise aus. Der Nachbau statt einer festen
|
||||||
|
// Pixelhöhe hält sich von selbst nach, wenn sich der Balken ändert.
|
||||||
|
<div className="invisible" aria-hidden>
|
||||||
|
<div className="fl-progress" />
|
||||||
|
<p className="mt-1 text-xs"> </p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
|
import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
|
||||||
import type { LibraryFacets } from "@/lib/queries";
|
import type { LibraryFacets } from "@/lib/queries";
|
||||||
|
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filterleiste der Bibliothek.
|
* Filterleiste der Bibliothek.
|
||||||
@@ -26,11 +27,19 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const [, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||||
const first = useRef(true);
|
const first = useRef(true);
|
||||||
|
|
||||||
|
// Der Trefferliste mitteilen, dass eine neue Antwort unterwegs ist — ohne
|
||||||
|
// das Signal blieben die alten Treffer stumm stehen und es sah aus, als
|
||||||
|
// passiere nichts.
|
||||||
|
const reportPending = useReportSearchPending();
|
||||||
|
useEffect(() => {
|
||||||
|
reportPending(pending);
|
||||||
|
}, [pending, reportPending]);
|
||||||
|
|
||||||
function apply(changes: Record<string, string | null>) {
|
function apply(changes: Record<string, string | null>) {
|
||||||
const next = new URLSearchParams(params.toString());
|
const next = new URLSearchParams(params.toString());
|
||||||
for (const [key, value] of Object.entries(changes)) {
|
for (const [key, value] of Object.entries(changes)) {
|
||||||
@@ -48,7 +57,7 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
|||||||
first.current = false;
|
first.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => apply({ q: query.trim() || null }), 300);
|
const timer = setTimeout(() => apply({ q: query.trim() || null }), 250);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query]);
|
}, [query]);
|
||||||
@@ -62,6 +71,7 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
@@ -70,6 +80,8 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
|||||||
aria-label="Bibliothek durchsuchen"
|
aria-label="Bibliothek durchsuchen"
|
||||||
className="fl-input"
|
className="fl-input"
|
||||||
/>
|
/>
|
||||||
|
<SearchSpinner show={pending} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern">
|
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern">
|
||||||
<Chip active={!status} onClick={() => apply({ status: null })}>
|
<Chip active={!status} onClick={() => apply({ status: null })}>
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { markMovieAction, undoAction, watchNextAction } from "@/app/actions/library";
|
import {
|
||||||
|
markMovieAction,
|
||||||
|
undoAction,
|
||||||
|
unmarkMovieAction,
|
||||||
|
watchNextAction,
|
||||||
|
} from "@/app/actions/library";
|
||||||
import { useToasts } from "@/components/Toasts";
|
import { useToasts } from "@/components/Toasts";
|
||||||
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
|
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
|
||||||
|
|
||||||
@@ -93,14 +98,31 @@ export function WatchMovieButton({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Im Zustand „gesehen" bleibt die grüne Plakette stehen, ist aber klickbar —
|
||||||
|
// so wie sich Episodenhäkchen auch wieder abwählen lassen. Vorher stand hier
|
||||||
|
// ein <p>: ein Etikett ohne Klickziel, aus dem es kein Zurück gab.
|
||||||
if (watched) {
|
if (watched) {
|
||||||
return (
|
return (
|
||||||
<p className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
title="Markierung wieder entfernen"
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await unmarkMovieAction(entryId));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="group inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)] transition-colors hover:border-[color-mix(in_oklab,var(--fl-success)_70%,transparent)] hover:bg-[color-mix(in_oklab,var(--fl-success)_20%,transparent)] disabled:opacity-60"
|
||||||
|
>
|
||||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
|
||||||
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
Gesehen
|
Gesehen
|
||||||
</p>
|
<span className="text-xs text-[color-mix(in_oklab,var(--fl-success)_75%,var(--fl-muted))]">
|
||||||
|
— Markierung entfernen
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export function ProfileForm({
|
|||||||
email: string;
|
email: string;
|
||||||
region: string;
|
region: string;
|
||||||
publicProfile: boolean;
|
publicProfile: boolean;
|
||||||
|
showAchievements: boolean;
|
||||||
profileSlug: string | null;
|
profileSlug: string | null;
|
||||||
notifyNewEpisode: boolean;
|
notifyNewEpisode: boolean;
|
||||||
notifyRelease: boolean;
|
notifyRelease: boolean;
|
||||||
@@ -121,6 +122,7 @@ export function ProfileForm({
|
|||||||
name: state.name,
|
name: state.name,
|
||||||
region: state.region,
|
region: state.region,
|
||||||
publicProfile: state.publicProfile,
|
publicProfile: state.publicProfile,
|
||||||
|
showAchievements: state.showAchievements,
|
||||||
profileSlug: state.profileSlug || null,
|
profileSlug: state.profileSlug || null,
|
||||||
notifyNewEpisode: state.notifyNewEpisode,
|
notifyNewEpisode: state.notifyNewEpisode,
|
||||||
notifyRelease: state.notifyRelease,
|
notifyRelease: state.notifyRelease,
|
||||||
@@ -190,6 +192,7 @@ export function ProfileForm({
|
|||||||
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
|
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
|
||||||
/>
|
/>
|
||||||
{state.publicProfile && (
|
{state.publicProfile && (
|
||||||
|
<>
|
||||||
<Field
|
<Field
|
||||||
id="profileSlug"
|
id="profileSlug"
|
||||||
label="Adresse"
|
label="Adresse"
|
||||||
@@ -198,6 +201,13 @@ export function ProfileForm({
|
|||||||
placeholder="dein-name"
|
placeholder="dein-name"
|
||||||
hint="Erreichbar unter /u/<adresse>"
|
hint="Erreichbar unter /u/<adresse>"
|
||||||
/>
|
/>
|
||||||
|
<Toggle
|
||||||
|
checked={state.showAchievements}
|
||||||
|
onChange={(value) => setState({ ...state, showAchievements: value })}
|
||||||
|
label="Errungenschaften zeigen"
|
||||||
|
hint="Die erreichten Abzeichen erscheinen auf der Profilseite."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
connectMediaServerAction,
|
||||||
|
deleteMediaServerAction,
|
||||||
|
importMediaServerTitlesAction,
|
||||||
|
syncMediaServerAction,
|
||||||
|
testMediaServerAction,
|
||||||
|
updateMediaServerAction,
|
||||||
|
type MediaServerResult,
|
||||||
|
} from "@/app/actions/settings";
|
||||||
|
import { useToasts } from "@/components/Toasts";
|
||||||
|
import { formatDate } from "@/lib/labels";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindung zu Jellyfin einrichten und abgleichen.
|
||||||
|
*
|
||||||
|
* Zwei Dinge stehen bewusst deutlich in der Oberfläche:
|
||||||
|
*
|
||||||
|
* - Der Verbindungstest sagt ausdrücklich, ob der **Gesehen-Status** ankommt.
|
||||||
|
* Mit einem reinen API-Key täte er das nicht — und der Abgleich liefe stumm
|
||||||
|
* ins Leere, ohne dass jemand wüsste, warum.
|
||||||
|
* - Das Aufnehmen fehlender Titel ist ein **Häkchen**, keine Selbstverständ-
|
||||||
|
* lichkeit. Eine Mediathek mit 800 Filmen soll nicht ungefragt zur
|
||||||
|
* Bibliothek werden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UnassignedItem = {
|
||||||
|
tmdbId: number | null;
|
||||||
|
typ: "SERIES" | "MOVIE" | null;
|
||||||
|
name: string;
|
||||||
|
grund: string;
|
||||||
|
dateien: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Verbindung = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
serverUserName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
syncOwned: boolean;
|
||||||
|
syncWatched: boolean;
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
/** Aus dem letzten Abgleich — Grundlage der Auswahlliste. */
|
||||||
|
offen: UnassignedItem[];
|
||||||
|
weitereOffen: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Pruefung = NonNullable<MediaServerResult["pruefung"]>;
|
||||||
|
type Bericht = NonNullable<MediaServerResult["bericht"]>;
|
||||||
|
|
||||||
|
export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { show } = useToasts();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const [baseUrl, setBaseUrl] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [aufnehmen, setAufnehmen] = useState(false);
|
||||||
|
const [pruefung, setPruefung] = useState<Pruefung | null>(null);
|
||||||
|
const [bericht, setBericht] = useState<Bericht | null>(null);
|
||||||
|
const [laeuft, setLaeuft] = useState(false);
|
||||||
|
|
||||||
|
async function verbinden() {
|
||||||
|
setLaeuft(true);
|
||||||
|
setPruefung(null);
|
||||||
|
try {
|
||||||
|
const ergebnis = await connectMediaServerAction({ baseUrl, username, password });
|
||||||
|
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
|
||||||
|
if (ergebnis.ok) {
|
||||||
|
setPruefung(ergebnis.pruefung ?? null);
|
||||||
|
setPassword("");
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testen(id: string) {
|
||||||
|
setLaeuft(true);
|
||||||
|
try {
|
||||||
|
const ergebnis = await testMediaServerAction(id);
|
||||||
|
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
|
||||||
|
setPruefung(ergebnis.pruefung ?? null);
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abgleichen(id: string) {
|
||||||
|
setLaeuft(true);
|
||||||
|
setBericht(null);
|
||||||
|
try {
|
||||||
|
const ergebnis = await syncMediaServerAction(id, aufnehmen);
|
||||||
|
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
|
||||||
|
setBericht(ergebnis.bericht ?? null);
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function umschalten(id: string, data: Partial<Verbindung>) {
|
||||||
|
const ergebnis = await updateMediaServerAction(id, data);
|
||||||
|
if (!ergebnis.ok) show(ergebnis.message ?? "", { tone: "error" });
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entfernen(id: string, name: string) {
|
||||||
|
const ergebnis = await deleteMediaServerAction(id);
|
||||||
|
show(ergebnis.ok ? `„${name}“ entfernt` : (ergebnis.message ?? ""), {
|
||||||
|
tone: ergebnis.ok ? "info" : "error",
|
||||||
|
});
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{verbindungen.length > 0 && (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{verbindungen.map((verbindung) => (
|
||||||
|
<li key={verbindung.id} className="rounded-xl border border-[var(--fl-border)] p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{verbindung.name}</p>
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
{verbindung.baseUrl} · angemeldet als {verbindung.serverUserName}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
|
||||||
|
{verbindung.lastSyncAt
|
||||||
|
? `zuletzt abgeglichen ${formatDate(verbindung.lastSyncAt)}`
|
||||||
|
: "noch nicht abgeglichen"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => entfernen(verbindung.id, verbindung.name)}
|
||||||
|
disabled={pending || laeuft}
|
||||||
|
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-4 text-sm">
|
||||||
|
<Haken
|
||||||
|
label="Im täglichen Abgleich mitlaufen"
|
||||||
|
checked={verbindung.enabled}
|
||||||
|
onChange={(wert) => umschalten(verbindung.id, { enabled: wert })}
|
||||||
|
/>
|
||||||
|
<Haken
|
||||||
|
label="Bestand übernehmen"
|
||||||
|
checked={verbindung.syncOwned}
|
||||||
|
onChange={(wert) => umschalten(verbindung.id, { syncOwned: wert })}
|
||||||
|
/>
|
||||||
|
<Haken
|
||||||
|
label="Sichtungen übernehmen"
|
||||||
|
checked={verbindung.syncWatched}
|
||||||
|
onChange={(wert) => umschalten(verbindung.id, { syncWatched: wert })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => abgleichen(verbindung.id)}
|
||||||
|
disabled={pending || laeuft}
|
||||||
|
className="fl-btn fl-btn-primary"
|
||||||
|
>
|
||||||
|
{laeuft ? "Läuft …" : "Jetzt abgleichen"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => testen(verbindung.id)}
|
||||||
|
disabled={pending || laeuft}
|
||||||
|
className="fl-btn fl-btn-ghost"
|
||||||
|
>
|
||||||
|
Verbindung prüfen
|
||||||
|
</button>
|
||||||
|
<Haken
|
||||||
|
label="Bekannte Titel dabei in die Bibliothek aufnehmen"
|
||||||
|
checked={aufnehmen}
|
||||||
|
onChange={setAufnehmen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pruefung && <Pruefbericht pruefung={pruefung} />}
|
||||||
|
{bericht && <Abgleichbericht bericht={bericht} />}
|
||||||
|
|
||||||
|
{verbindungen.map((verbindung) =>
|
||||||
|
verbindung.offen.length > 0 ? (
|
||||||
|
<Nachladeliste
|
||||||
|
key={`offen-${verbindung.id}`}
|
||||||
|
verbindung={verbindung}
|
||||||
|
laeuft={laeuft || pending}
|
||||||
|
onImport={async (auswahl) => {
|
||||||
|
setLaeuft(true);
|
||||||
|
try {
|
||||||
|
const ergebnis = await importMediaServerTitlesAction(verbindung.id, auswahl);
|
||||||
|
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verbindungen.length === 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-[var(--fl-muted)]">
|
||||||
|
Flimmer kann aus Jellyfin übernehmen, was du besitzt und was du dort schon gesehen
|
||||||
|
hast. Zugeordnet wird über die TMDB-Kennung, die Jellyfin mitliefert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<label className="space-y-1 text-sm">
|
||||||
|
<span className="text-[var(--fl-muted)]">Adresse</span>
|
||||||
|
<input
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(event) => setBaseUrl(event.target.value)}
|
||||||
|
placeholder="http://jellyfin.fritz.box:8096"
|
||||||
|
className="fl-input py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1 text-sm">
|
||||||
|
<span className="text-[var(--fl-muted)]">Benutzername</span>
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
className="fl-input py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1 text-sm">
|
||||||
|
<span className="text-[var(--fl-muted)]">Passwort</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
className="fl-input py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
Bewusst Benutzername und Passwort statt eines API-Schlüssels: mit einem Schlüssel
|
||||||
|
liefert Jellyfin den Bestand ohne Gesehen-Status. Das Zugangstoken wird verschlüsselt
|
||||||
|
abgelegt, dein Passwort nicht gespeichert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={verbinden}
|
||||||
|
disabled={laeuft || !baseUrl || !username || !password}
|
||||||
|
className="fl-btn fl-btn-primary"
|
||||||
|
>
|
||||||
|
{laeuft ? "Verbinde …" : "Verbinden"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pruefbericht({ pruefung }: { pruefung: Pruefung }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-2)] p-4 text-sm">
|
||||||
|
<p className="font-medium">
|
||||||
|
{pruefung.serverName}
|
||||||
|
{pruefung.version ? ` · Version ${pruefung.version}` : ""}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[var(--fl-muted)]">
|
||||||
|
{pruefung.itemCount} Einträge in der Mediathek, davon {pruefung.withTmdbId} der ersten 50
|
||||||
|
mit TMDB-Kennung.
|
||||||
|
</p>
|
||||||
|
{pruefung.hasUserData ? (
|
||||||
|
<p className="mt-2 text-[var(--fl-success)]">✓ Der Gesehen-Status kommt mit.</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-[var(--fl-warning)]">
|
||||||
|
! Der Gesehen-Status fehlt. Das passiert, wenn die Anmeldung über einen API-Schlüssel
|
||||||
|
statt über ein Benutzerkonto läuft — dann kann Flimmer nur den Bestand übernehmen, keine
|
||||||
|
Sichtungen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Abgleichbericht({ bericht }: { bericht: Bericht }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-2)] p-4 text-sm">
|
||||||
|
<p className="font-medium">Bericht</p>
|
||||||
|
<ul className="mt-1 space-y-0.5 text-[var(--fl-muted)]">
|
||||||
|
<li>{bericht.gelesen} Einträge gelesen</li>
|
||||||
|
<li>{bericht.bestandGesetzt}× „hab ich“ gesetzt</li>
|
||||||
|
<li>{bericht.sichtungenAngelegt} Sichtungen übernommen</li>
|
||||||
|
{bericht.aufgenommen > 0 && <li>{bericht.aufgenommen} Titel neu aufgenommen</li>}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{bericht.nichtZugeordnet.length > 0 && (
|
||||||
|
<details className="mt-3">
|
||||||
|
<summary className="cursor-pointer text-[var(--fl-muted)]">
|
||||||
|
{bericht.nichtZugeordnet.length} nicht zuzuordnen
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-2 space-y-0.5 text-xs text-[var(--fl-faint)]">
|
||||||
|
{bericht.nichtZugeordnet.map((eintrag, index) => (
|
||||||
|
<li key={`${eintrag.name}-${index}`}>
|
||||||
|
{eintrag.name} — {eintrag.grund}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bericht.fehler.length > 0 && (
|
||||||
|
<ul className="mt-3 space-y-0.5 text-xs text-[var(--fl-danger)]">
|
||||||
|
{bericht.fehler.map((fehler, index) => (
|
||||||
|
<li key={index}>{fehler}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Haken({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (wert: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(event) => onChange(event.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-[var(--fl-border-strong)] accent-[var(--fl-accent)]"
|
||||||
|
/>
|
||||||
|
<span className="text-[var(--fl-muted)]">{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auswahlliste für Titel, die Flimmer noch nicht kennt.
|
||||||
|
*
|
||||||
|
* Der Unterschied, der in der Oberfläche sichtbar sein muss: Das Häkchen oben
|
||||||
|
* nimmt auf, was Flimmer **kennt**. Diese Liste holt, was es **nicht kennt** —
|
||||||
|
* und zwar nur das, was hier angehakt wird. Bei über tausend Einträgen in
|
||||||
|
* einer Mediathek wäre „alles oder nichts" keine Wahl.
|
||||||
|
*/
|
||||||
|
function Nachladeliste({
|
||||||
|
verbindung,
|
||||||
|
laeuft,
|
||||||
|
onImport,
|
||||||
|
}: {
|
||||||
|
verbindung: Verbindung;
|
||||||
|
laeuft: boolean;
|
||||||
|
onImport: (auswahl: { tmdbId: number; typ: "SERIES" | "MOVIE" }[]) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [suche, setSuche] = useState("");
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const gefiltert = verbindung.offen.filter((eintrag) =>
|
||||||
|
eintrag.name.toLocaleLowerCase("de-DE").includes(suche.trim().toLocaleLowerCase("de-DE")),
|
||||||
|
);
|
||||||
|
// Ohne TMDB-Kennung lässt sich nichts holen — die Zeilen bleiben sichtbar,
|
||||||
|
// aber nicht anklickbar. Sie zu verstecken würde die Frage aufwerfen, wo
|
||||||
|
// sie geblieben sind.
|
||||||
|
const holbar = gefiltert.filter((eintrag) => eintrag.tmdbId != null);
|
||||||
|
const ohneKennung = verbindung.offen.length - verbindung.offen.filter((e) => e.tmdbId != null).length;
|
||||||
|
|
||||||
|
function umschalten(tmdbId: number) {
|
||||||
|
setGewaehlt((vorher) => {
|
||||||
|
const neu = new Set(vorher);
|
||||||
|
if (neu.has(tmdbId)) neu.delete(tmdbId);
|
||||||
|
else neu.add(tmdbId);
|
||||||
|
return neu;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const alleGewaehlt = holbar.length > 0 && holbar.every((e) => gewaehlt.has(e.tmdbId!));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--fl-border)] p-4">
|
||||||
|
<h3 className="font-medium">Noch nicht in Flimmer</h3>
|
||||||
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
{verbindung.offen.length} Titel liegen auf {verbindung.name}, sind Flimmer aber unbekannt.
|
||||||
|
Wähle aus, was übernommen werden soll — die Daten kommen dann von TMDB.
|
||||||
|
{verbindung.weitereOffen > 0 && ` (${verbindung.weitereOffen} weitere nicht aufgeführt)`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{ohneKennung > 0 && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||||
|
Bei {ohneKennung} davon fehlt die TMDB-Kennung in Jellyfin selbst — die lassen sich nicht
|
||||||
|
automatisch holen. Meist hilft es, die Erkennung in Jellyfin nachzuziehen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={suche}
|
||||||
|
onChange={(event) => setSuche(event.target.value)}
|
||||||
|
placeholder="In der Liste suchen …"
|
||||||
|
aria-label="Liste durchsuchen"
|
||||||
|
className="fl-input w-56 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setGewaehlt(alleGewaehlt ? new Set() : new Set(holbar.map((e) => e.tmdbId!)))
|
||||||
|
}
|
||||||
|
disabled={holbar.length === 0}
|
||||||
|
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
|
||||||
|
>
|
||||||
|
{alleGewaehlt ? "Auswahl aufheben" : `Alle ${holbar.length} auswählen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto pr-1">
|
||||||
|
{gefiltert.map((eintrag) => {
|
||||||
|
const holbarEintrag = eintrag.tmdbId != null;
|
||||||
|
return (
|
||||||
|
<li key={eintrag.tmdbId ?? `name-${eintrag.name}`}>
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
"flex items-start gap-2 rounded-lg px-2 py-1.5 text-sm",
|
||||||
|
holbarEintrag ? "hover:bg-[var(--fl-surface-2)]" : "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={!holbarEintrag}
|
||||||
|
checked={holbarEintrag && gewaehlt.has(eintrag.tmdbId!)}
|
||||||
|
onChange={() => holbarEintrag && umschalten(eintrag.tmdbId!)}
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 rounded border-[var(--fl-border-strong)] accent-[var(--fl-accent)]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate">{eintrag.name}</span>
|
||||||
|
<span className="block text-xs text-[var(--fl-faint)]">
|
||||||
|
{holbarEintrag
|
||||||
|
? `${eintrag.typ === "SERIES" ? "Serie" : "Film"} · TMDB ${eintrag.tmdbId}`
|
||||||
|
: "ohne TMDB-Kennung"}
|
||||||
|
{eintrag.dateien > 1 && ` · ${eintrag.dateien} Dateien`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{gefiltert.length === 0 && (
|
||||||
|
<li className="px-2 py-1.5 text-sm text-[var(--fl-faint)]">Nichts gefunden.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={laeuft || gewaehlt.size === 0}
|
||||||
|
onClick={() =>
|
||||||
|
onImport(
|
||||||
|
verbindung.offen
|
||||||
|
.filter((eintrag) => eintrag.tmdbId != null && gewaehlt.has(eintrag.tmdbId))
|
||||||
|
.map((eintrag) => ({ tmdbId: eintrag.tmdbId!, typ: eintrag.typ ?? "MOVIE" })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="fl-btn fl-btn-primary mt-3"
|
||||||
|
>
|
||||||
|
{laeuft
|
||||||
|
? "Läuft …"
|
||||||
|
: gewaehlt.size === 0
|
||||||
|
? "Nichts ausgewählt"
|
||||||
|
: `${gewaehlt.size} ${gewaehlt.size === 1 ? "Titel" : "Titel"} übernehmen`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{gewaehlt.size > 100 && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||||
|
Es werden 100 auf einmal geholt; der Rest bleibt ausgewählt für den nächsten Lauf.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
startAuthentication,
|
||||||
|
startRegistration,
|
||||||
|
WebAuthnError,
|
||||||
|
} from "@simplewebauthn/browser";
|
||||||
|
import { useToasts } from "@/components/Toasts";
|
||||||
|
import { formatDate } from "@/lib/labels";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passkeys anlegen und verwalten.
|
||||||
|
*
|
||||||
|
* Der Browser übernimmt den heiklen Teil; hier laufen nur zwei Anfragen —
|
||||||
|
* Optionen holen, Antwort einreichen. Fehler des Authenticators tragen
|
||||||
|
* kryptische Namen, deshalb werden die häufigen übersetzt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PasskeyEintrag = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
deviceType: string;
|
||||||
|
backedUp: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Übersetzt die Fehler des Browsers in Sätze, mit denen man etwas anfangen kann. */
|
||||||
|
export function passkeyFehlertext(error: unknown): string | null {
|
||||||
|
if (error instanceof WebAuthnError) {
|
||||||
|
switch (error.name) {
|
||||||
|
case "InvalidStateError":
|
||||||
|
return "Dieses Gerät ist hier schon hinterlegt.";
|
||||||
|
case "NotAllowedError":
|
||||||
|
// Auch der Abbruch durch den Nutzer landet hier — dann ist Schweigen
|
||||||
|
// die richtige Antwort.
|
||||||
|
return null;
|
||||||
|
case "SecurityError":
|
||||||
|
return "Passkeys brauchen HTTPS oder localhost. Über diese Adresse geht es nicht.";
|
||||||
|
default:
|
||||||
|
return "Der Passkey konnte nicht verwendet werden.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error instanceof DOMException && error.name === "NotAllowedError") return null;
|
||||||
|
return "Das hat nicht geklappt.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasskeyList({
|
||||||
|
passkeys,
|
||||||
|
available,
|
||||||
|
hinweis,
|
||||||
|
}: {
|
||||||
|
passkeys: PasskeyEintrag[];
|
||||||
|
available: boolean;
|
||||||
|
hinweis: string | null;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { show } = useToasts();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
async function anlegen() {
|
||||||
|
try {
|
||||||
|
const antwort = await fetch("/api/auth/passkey/registrierung/start", { method: "POST" });
|
||||||
|
const optionen = await antwort.json();
|
||||||
|
if (!antwort.ok) {
|
||||||
|
show(optionen.error ?? "Das hat nicht geklappt.", { tone: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ergebnis = await startRegistration({ optionsJSON: optionen });
|
||||||
|
|
||||||
|
const fertig = await fetch("/api/auth/passkey/registrierung/abschluss", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: name.trim() || geraeteVorschlag(), response: ergebnis }),
|
||||||
|
});
|
||||||
|
const daten = await fertig.json();
|
||||||
|
if (!fertig.ok) {
|
||||||
|
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setName("");
|
||||||
|
show("Passkey angelegt");
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (error) {
|
||||||
|
const text = passkeyFehlertext(error);
|
||||||
|
if (text) show(text, { tone: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function umbenennen(id: string, alt: string, neu: string) {
|
||||||
|
const name = neu.trim();
|
||||||
|
if (!name || name === alt) return;
|
||||||
|
|
||||||
|
const antwort = await fetch(`/api/passkeys/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!antwort.ok) {
|
||||||
|
const daten = await antwort.json().catch(() => ({}));
|
||||||
|
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
show("Umbenannt");
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entfernen(id: string, wie: string) {
|
||||||
|
const antwort = await fetch(`/api/passkeys/${id}`, { method: "DELETE" });
|
||||||
|
const daten = await antwort.json().catch(() => ({}));
|
||||||
|
if (!antwort.ok) {
|
||||||
|
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
show(`„${wie}" entfernt`);
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!available) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
|
||||||
|
{hinweis}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{passkeys.length > 0 ? (
|
||||||
|
<ul className="divide-y divide-[var(--fl-border)]">
|
||||||
|
{passkeys.map((passkey) => (
|
||||||
|
<li key={passkey.id} className="flex flex-wrap items-center gap-3 py-2.5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Der Name ist direkt überschreibbar — ein eigener
|
||||||
|
Bearbeiten-Modus wäre für ein einzelnes Feld zu viel. */}
|
||||||
|
<input
|
||||||
|
defaultValue={passkey.name}
|
||||||
|
aria-label={`Name von „${passkey.name}"`}
|
||||||
|
maxLength={60}
|
||||||
|
onBlur={(event) => umbenennen(passkey.id, passkey.name, event.target.value)}
|
||||||
|
className="w-full truncate rounded-md border border-transparent bg-transparent px-1 py-0.5 font-medium transition-colors hover:border-[var(--fl-border)] focus:border-[var(--fl-border-strong)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<p className="px-1 text-xs text-[var(--fl-faint)]">
|
||||||
|
{passkey.backedUp ? "synchronisiert" : "nur auf diesem Gerät"}
|
||||||
|
{" · angelegt "}
|
||||||
|
{formatDate(passkey.createdAt)}
|
||||||
|
{passkey.lastUsedAt
|
||||||
|
? ` · zuletzt benutzt ${formatDate(passkey.lastUsedAt)}`
|
||||||
|
: " · noch nicht benutzt"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => entfernen(passkey.id, passkey.name)}
|
||||||
|
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--fl-muted)]">
|
||||||
|
Noch kein Passkey hinterlegt. Mit einem kannst du dich ohne Passwort anmelden — per
|
||||||
|
Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder={geraeteVorschlag()}
|
||||||
|
aria-label="Name für den neuen Passkey"
|
||||||
|
maxLength={60}
|
||||||
|
className="fl-input w-56 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={anlegen} disabled={pending} className="fl-btn fl-btn-primary">
|
||||||
|
Passkey anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{passkeys.some((passkey) => !passkey.backedUp) && (
|
||||||
|
<p className="text-xs text-[var(--fl-faint)]">
|
||||||
|
Ein Passkey, der nur auf einem Gerät liegt, ist mit dem Gerät verloren. Halte fürs
|
||||||
|
Erste einen zweiten Anmeldeweg bereit.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein brauchbarer Vorschlag, damit niemand „Passkey 1" tippen muss. */
|
||||||
|
function geraeteVorschlag(): string {
|
||||||
|
if (typeof navigator === "undefined") return "Mein Gerät";
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/iPhone/.test(ua)) return "iPhone";
|
||||||
|
if (/iPad/.test(ua)) return "iPad";
|
||||||
|
if (/Android/.test(ua)) return "Android-Gerät";
|
||||||
|
if (/Mac OS X/.test(ua)) return "Mac";
|
||||||
|
if (/Windows/.test(ua)) return "Windows-PC";
|
||||||
|
if (/Linux/.test(ua)) return "Linux-Rechner";
|
||||||
|
return "Mein Gerät";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Knopf auf der Anmeldeseite. */
|
||||||
|
export function PasskeyLoginButton({ label }: { label: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function anmelden() {
|
||||||
|
setFehler(null);
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
const antwort = await fetch("/api/auth/passkey/anmeldung/start", { method: "POST" });
|
||||||
|
const optionen = await antwort.json();
|
||||||
|
if (!antwort.ok) {
|
||||||
|
setFehler(optionen.error ?? "Das hat nicht geklappt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ergebnis = await startAuthentication({ optionsJSON: optionen });
|
||||||
|
|
||||||
|
const fertig = await fetch("/api/auth/passkey/anmeldung/abschluss", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(ergebnis),
|
||||||
|
});
|
||||||
|
const daten = await fertig.json();
|
||||||
|
if (!fertig.ok) {
|
||||||
|
setFehler(daten.error ?? "Das hat nicht geklappt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace("/");
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
setFehler(passkeyFehlertext(error));
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={anmelden}
|
||||||
|
disabled={pending}
|
||||||
|
className="fl-btn fl-btn-ghost w-full justify-center gap-2"
|
||||||
|
>
|
||||||
|
<KeyIcon />
|
||||||
|
{pending ? "Einen Moment …" : label}
|
||||||
|
</button>
|
||||||
|
{fehler && (
|
||||||
|
<p className="text-sm text-[var(--fl-danger)]" role="alert">
|
||||||
|
{fehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||||
|
<circle cx="8" cy="12" r="3.5" />
|
||||||
|
<path d="M11.5 12H21m-3 0v3m-3-3v2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
createOrderAction,
|
createOrderAction,
|
||||||
createUniverseAction,
|
createUniverseAction,
|
||||||
deleteOrderAction,
|
deleteOrderAction,
|
||||||
|
setDefaultOrderAction,
|
||||||
shareUniverseAction,
|
shareUniverseAction,
|
||||||
trashUniverseAction,
|
trashUniverseAction,
|
||||||
unshareUniverseAction,
|
unshareUniverseAction,
|
||||||
@@ -109,6 +110,24 @@ export function OrderTabs({
|
|||||||
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||||
>
|
>
|
||||||
{order.name}
|
{order.name}
|
||||||
|
{order.isDefault && (
|
||||||
|
<>
|
||||||
|
{/* Häkchen an der Reihenfolge, die beim Öffnen erscheint. Das
|
||||||
|
Wort dahinter steht nur für Screenreader — sichtbar würde
|
||||||
|
es die Reiter auseinanderziehen. */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="ml-1.5 inline h-3.5 w-3.5 align-[-2px]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span className="sr-only"> (Standard)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -158,6 +177,21 @@ export function OrderTabs({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{active && !active.isDefault && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
startTransition(async () => {
|
||||||
|
report(await setDefaultOrderAction(active.id));
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
|
||||||
|
>
|
||||||
|
Als Standard festlegen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{active && active.kind === "CUSTOM" && (
|
{active && active.kind === "CUSTOM" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Eigennamen für bekannte Filmreihen.
|
||||||
|
*
|
||||||
|
* Die Grundlage ist generisch: „Reihe komplett — Spider-Man" funktioniert für
|
||||||
|
* jede Reihe, die TMDB kennt, und braucht keine Pflege. Diese Datei setzt
|
||||||
|
* obendrauf: wo ein Eintrag passt, bekommt das Abzeichen einen Namen mit
|
||||||
|
* Augenzwinkern. Fehlt er, greift der generische — nichts hängt davon ab.
|
||||||
|
*
|
||||||
|
* Die Schlüssel sind **TMDB-Collection-Kennungen**, nachzuschlagen unter
|
||||||
|
* `themoviedb.org/collection/<id>`.
|
||||||
|
*
|
||||||
|
* ## Warum jeder Eintrag ein `erwartet` hat
|
||||||
|
*
|
||||||
|
* Eine falsch abgeschriebene Kennung wäre schlimmer als gar kein Eintrag: Wer
|
||||||
|
* die Ocean's-Reihe durchhat, bekäme „Spider Bro". Deshalb trägt jeder Eintrag
|
||||||
|
* ein Stück des Namens, den TMDB für diese Reihe führt. Passt es nicht zum
|
||||||
|
* tatsächlichen Namen, wird der Eintrag ignoriert und der generische Name
|
||||||
|
* greift. Ein Tippfehler bleibt damit folgenlos.
|
||||||
|
*
|
||||||
|
* Erweitern ist ausdrücklich erwünscht — eine Zeile hier, sonst nichts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SymbolName } from "./achievements";
|
||||||
|
|
||||||
|
export type KuratierteReihe = {
|
||||||
|
name: string;
|
||||||
|
symbol: SymbolName;
|
||||||
|
/** Muss im TMDB-Namen der Reihe vorkommen, sonst gilt der Eintrag nicht. */
|
||||||
|
erwartet: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const REIHEN: Record<number, KuratierteReihe> = {
|
||||||
|
556: { name: "Spider Bro", symbol: "spinne", erwartet: "Spider-Man" },
|
||||||
|
531241: { name: "Spider Bro", symbol: "spinne", erwartet: "Spider-Man" },
|
||||||
|
119: { name: "Ringträger", symbol: "kreis", erwartet: "Herr der Ringe" },
|
||||||
|
121938: { name: "Auf unerwarteter Reise", symbol: "berg", erwartet: "Hobbit" },
|
||||||
|
10: { name: "Möge die Macht", symbol: "stern", erwartet: "Star Wars" },
|
||||||
|
264: { name: "Wo wir hinfahren", symbol: "sanduhr", erwartet: "Zurück in die Zukunft" },
|
||||||
|
2344: { name: "Aufgewacht", symbol: "kreis", erwartet: "Matrix" },
|
||||||
|
84: { name: "Gehört ins Museum", symbol: "kompass", erwartet: "Indiana Jones" },
|
||||||
|
8091: { name: "Im All hört dich niemand", symbol: "planet", erwartet: "Alien" },
|
||||||
|
328: { name: "Leben findet einen Weg", symbol: "spross", erwartet: "Jurassic Park" },
|
||||||
|
295: { name: "Freibeuter", symbol: "kompass", erwartet: "Fluch der Karibik" },
|
||||||
|
1241: { name: "Ganze sieben Jahre", symbol: "schluessel", erwartet: "Harry Potter" },
|
||||||
|
528: { name: "Ich komme wieder", symbol: "kreis", erwartet: "Terminator" },
|
||||||
|
230: { name: "Ein Angebot", symbol: "maske", erwartet: "Pate" },
|
||||||
|
10194: { name: "Bis zur Unendlichkeit", symbol: "planet", erwartet: "Toy Story" },
|
||||||
|
2150: { name: "Aus dem Sumpf", symbol: "spross", erwartet: "Shrek" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der kuratierte Eintrag — aber nur, wenn er zum tatsächlichen Namen passt.
|
||||||
|
*
|
||||||
|
* `tmdbName` ist der Name, den TMDB für diese Reihe liefert. Fehlt er, lässt
|
||||||
|
* sich nichts prüfen; dann gilt der Eintrag im Zweifel nicht.
|
||||||
|
*/
|
||||||
|
export function kuratierteReihe(
|
||||||
|
tmdbCollectionId: number,
|
||||||
|
tmdbName: string | null | undefined,
|
||||||
|
): KuratierteReihe | undefined {
|
||||||
|
const eintrag = REIHEN[tmdbCollectionId];
|
||||||
|
if (!eintrag || !tmdbName) return undefined;
|
||||||
|
|
||||||
|
const passt = tmdbName.toLocaleLowerCase("de-DE").includes(eintrag.erwartet.toLocaleLowerCase("de-DE"));
|
||||||
|
return passt ? eintrag : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur für Tests und Übersichten. */
|
||||||
|
export const ALLE_KURATIERTEN_REIHEN = REIHEN;
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { kuratierteReihe } from "./achievement-names";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Errungenschaften.
|
||||||
|
*
|
||||||
|
* Grundhaltung: **still belohnen, nicht antreiben.** Keine Streaks, die einen
|
||||||
|
* bestrafen, wenn eine Woche nichts läuft. Keine Fortschrittsbalken, die
|
||||||
|
* drängeln. Ein Abzeichen taucht auf, wenn etwas erreicht ist — und bleibt.
|
||||||
|
*
|
||||||
|
* Zwei Entscheidungen, die das Verhalten prägen:
|
||||||
|
*
|
||||||
|
* - **Einmal erreicht, bleibt erreicht.** Wer eine Sichtung zurücknimmt,
|
||||||
|
* verliert kein Abzeichen. Es wieder einzukassieren wäre kleinlich, und die
|
||||||
|
* Datenlage ändert sich schon durch einen Fehlklick.
|
||||||
|
* - **Alle Regeln arbeiten auf *einem* Datensatz**, der einmal geholt wird.
|
||||||
|
* Sonst stellt jede Regel ihre eigene Datenbankabfrage, und aus zwanzig
|
||||||
|
* Abzeichen werden zwanzig Abfragen bei jedem Abhaken.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AchievementKey = string;
|
||||||
|
|
||||||
|
/** Ein Abzeichen, wie es die Oberfläche braucht. */
|
||||||
|
export type AchievementView = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
beschreibung: string;
|
||||||
|
symbol: SymbolName;
|
||||||
|
gruppe: Gruppe;
|
||||||
|
unlockedAt: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Gruppe = "anfaenge" | "reihen" | "ausdauer" | "breite" | "ordnung";
|
||||||
|
|
||||||
|
export const GRUPPEN: { key: Gruppe; label: string }[] = [
|
||||||
|
{ key: "anfaenge", label: "Anfänge" },
|
||||||
|
{ key: "reihen", label: "Reihen und Universen" },
|
||||||
|
{ key: "ausdauer", label: "Ausdauer" },
|
||||||
|
{ key: "breite", label: "Breite" },
|
||||||
|
{ key: "ordnung", label: "Ordnung" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type SymbolName =
|
||||||
|
| "spross"
|
||||||
|
| "haken"
|
||||||
|
| "schluessel"
|
||||||
|
| "haus"
|
||||||
|
| "klappe"
|
||||||
|
| "planet"
|
||||||
|
| "kompass"
|
||||||
|
| "bildschirm"
|
||||||
|
| "filmrolle"
|
||||||
|
| "sonne"
|
||||||
|
| "kalender"
|
||||||
|
| "laeufer"
|
||||||
|
| "berg"
|
||||||
|
| "herz"
|
||||||
|
| "kreis"
|
||||||
|
| "maske"
|
||||||
|
| "sanduhr"
|
||||||
|
| "stern"
|
||||||
|
| "salz"
|
||||||
|
| "scheibe"
|
||||||
|
| "besen"
|
||||||
|
| "mond"
|
||||||
|
| "spinne";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Der Datensatz, auf dem alle Regeln arbeiten
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Datensatz = {
|
||||||
|
/** Zeitzone des Nutzers — „nachts um vier" ist seine Uhrzeit, nicht die des Servers. */
|
||||||
|
zeitzone: string;
|
||||||
|
eintraege: number;
|
||||||
|
minutenGesamt: number;
|
||||||
|
episodenGesehen: number;
|
||||||
|
filmeGesehen: number;
|
||||||
|
bewertungen: number;
|
||||||
|
niedrigsteBewertung: number | null;
|
||||||
|
besitzt: number;
|
||||||
|
watchlist: number;
|
||||||
|
serienAbgeschlossen: { titleId: string; episoden: number }[];
|
||||||
|
hoechsterDurchlauf: number;
|
||||||
|
genres: Set<string>;
|
||||||
|
jahrzehnte: Set<number>;
|
||||||
|
/** Größter Abstand zwischen erster und letzter Sichtung derselben Serie, in Tagen. */
|
||||||
|
laengsteTreue: number;
|
||||||
|
/** Meiste Minuten an einem Kalendertag (Zeitzone des Nutzers). */
|
||||||
|
minutenBesterTag: number;
|
||||||
|
/** Gab es eine Sichtung zwischen 3 und 5 Uhr? */
|
||||||
|
nachtsGesehen: boolean;
|
||||||
|
hatPasskey: boolean;
|
||||||
|
hatMedienserverLauf: boolean;
|
||||||
|
/** Vollständig gesehene TMDB-Reihen: Kennung → Name der Reihe. */
|
||||||
|
reihen: Map<number, string>;
|
||||||
|
/** Vollständig abgeschlossene Universen: Kennung → Name. */
|
||||||
|
universen: Map<string, string>;
|
||||||
|
/** Universen, die über eine eigene Reihenfolge abgeschlossen wurden. */
|
||||||
|
eigeneReihenfolge: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sammle(userId: string): Promise<Datensatz> {
|
||||||
|
// Zeitzone: Flimmer speichert sie nicht je Nutzer, also gilt die der Instanz
|
||||||
|
// (TZ in der .env). An einer Stelle, damit ein späterer Wechsel auf eine
|
||||||
|
// Zeitzone je Konto nur hier ansetzen muss.
|
||||||
|
const zeitzone = process.env.TZ || "Europe/Berlin";
|
||||||
|
|
||||||
|
const [eintraege, watches, serien, universen] = await Promise.all([
|
||||||
|
prisma.libraryEntry.findMany({
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
titleId: true,
|
||||||
|
status: true,
|
||||||
|
rating: true,
|
||||||
|
owned: true,
|
||||||
|
currentRun: true,
|
||||||
|
title: { select: { type: true, genres: true, releaseDate: true, tmdbCollectionId: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.watch.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { titleId: true, episodeId: true, watchedAt: true, minutes: true },
|
||||||
|
orderBy: { watchedAt: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.title.findMany({
|
||||||
|
where: { entries: { some: { userId, deletedAt: null } }, type: "SERIES" },
|
||||||
|
select: { id: true, episodeCount: true, title: true, tmdbCollectionId: true },
|
||||||
|
}),
|
||||||
|
prisma.universe.findMany({
|
||||||
|
where: { ownerId: userId, deletedAt: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
items: { select: { titleId: true } },
|
||||||
|
orders: { select: { kind: true, isDefault: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [passkeys, medienserver] = await Promise.all([
|
||||||
|
prisma.passkey.count({ where: { userId } }),
|
||||||
|
prisma.mediaServerConnection.count({ where: { userId, lastSyncAt: { not: null } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- Aus den Sichtungen ableiten ---
|
||||||
|
const minutenGesamt = watches.reduce((summe, watch) => summe + watch.minutes, 0);
|
||||||
|
const episodenGesehen = watches.filter((watch) => watch.episodeId !== null).length;
|
||||||
|
const filmeGesehen = watches.filter((watch) => watch.episodeId === null).length;
|
||||||
|
|
||||||
|
const proTag = new Map<string, number>();
|
||||||
|
const spanneProTitel = new Map<string, { erste: Date; letzte: Date }>();
|
||||||
|
let nachtsGesehen = false;
|
||||||
|
|
||||||
|
for (const watch of watches) {
|
||||||
|
const tag = tagIn(watch.watchedAt, zeitzone);
|
||||||
|
proTag.set(tag, (proTag.get(tag) ?? 0) + watch.minutes);
|
||||||
|
|
||||||
|
const stunde = stundeIn(watch.watchedAt, zeitzone);
|
||||||
|
if (stunde >= 3 && stunde < 5) nachtsGesehen = true;
|
||||||
|
|
||||||
|
const spanne = spanneProTitel.get(watch.titleId);
|
||||||
|
if (spanne) spanne.letzte = watch.watchedAt;
|
||||||
|
else spanneProTitel.set(watch.titleId, { erste: watch.watchedAt, letzte: watch.watchedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutenBesterTag = Math.max(0, ...proTag.values());
|
||||||
|
const laengsteTreue = Math.max(
|
||||||
|
0,
|
||||||
|
...[...spanneProTitel.values()].map(
|
||||||
|
(spanne) => (spanne.letzte.getTime() - spanne.erste.getTime()) / 86_400_000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Aus den Einträgen ableiten ---
|
||||||
|
const genres = new Set<string>();
|
||||||
|
const jahrzehnte = new Set<number>();
|
||||||
|
const gesehenTitel = new Set(watches.map((watch) => watch.titleId));
|
||||||
|
|
||||||
|
let bewertungen = 0;
|
||||||
|
let niedrigsteBewertung: number | null = null;
|
||||||
|
let besitzt = 0;
|
||||||
|
let watchlist = 0;
|
||||||
|
let hoechsterDurchlauf = 0;
|
||||||
|
|
||||||
|
for (const eintrag of eintraege) {
|
||||||
|
if (eintrag.rating != null) {
|
||||||
|
bewertungen += 1;
|
||||||
|
niedrigsteBewertung =
|
||||||
|
niedrigsteBewertung == null ? eintrag.rating : Math.min(niedrigsteBewertung, eintrag.rating);
|
||||||
|
}
|
||||||
|
if (eintrag.owned) besitzt += 1;
|
||||||
|
if (eintrag.status === "WATCHLIST") watchlist += 1;
|
||||||
|
hoechsterDurchlauf = Math.max(hoechsterDurchlauf, eintrag.currentRun);
|
||||||
|
|
||||||
|
// Genres und Jahrzehnte zählen nur, was tatsächlich gesehen wurde.
|
||||||
|
if (gesehenTitel.has(eintrag.titleId)) {
|
||||||
|
for (const genre of eintrag.title.genres) genres.add(genre);
|
||||||
|
if (eintrag.title.releaseDate) {
|
||||||
|
jahrzehnte.add(Math.floor(eintrag.title.releaseDate.getFullYear() / 10) * 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Abgeschlossene Serien ---
|
||||||
|
const episodenProSerie = new Map<string, number>();
|
||||||
|
for (const watch of watches) {
|
||||||
|
if (watch.episodeId === null) continue;
|
||||||
|
episodenProSerie.set(watch.titleId, (episodenProSerie.get(watch.titleId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const serienAbgeschlossen = serien
|
||||||
|
.filter(
|
||||||
|
(serie) =>
|
||||||
|
serie.episodeCount > 0 && (episodenProSerie.get(serie.id) ?? 0) >= serie.episodeCount,
|
||||||
|
)
|
||||||
|
.map((serie) => ({ titleId: serie.id, episoden: serie.episodeCount }));
|
||||||
|
|
||||||
|
// --- Vollständig gesehene TMDB-Reihen ---
|
||||||
|
// Eine Reihe zählt, wenn alle Filme der Reihe, die im Katalog stehen,
|
||||||
|
// gesehen sind — und es mindestens zwei sind. Ein einzelner Film ist keine
|
||||||
|
// Reihe.
|
||||||
|
const reihen = new Map<number, string>();
|
||||||
|
const reihenIds = [
|
||||||
|
...new Set(
|
||||||
|
eintraege
|
||||||
|
.map((eintrag) => eintrag.title.tmdbCollectionId)
|
||||||
|
.filter((id): id is number => id != null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (reihenIds.length > 0) {
|
||||||
|
const reihenTitel = await prisma.title.findMany({
|
||||||
|
where: { tmdbCollectionId: { in: reihenIds } },
|
||||||
|
select: { id: true, tmdbCollectionId: true, tmdbCollectionName: true },
|
||||||
|
});
|
||||||
|
const proReihe = new Map<number, { id: string; name: string | null }[]>();
|
||||||
|
for (const titel of reihenTitel) {
|
||||||
|
const liste = proReihe.get(titel.tmdbCollectionId!) ?? [];
|
||||||
|
liste.push({ id: titel.id, name: titel.tmdbCollectionName });
|
||||||
|
proReihe.set(titel.tmdbCollectionId!, liste);
|
||||||
|
}
|
||||||
|
for (const [id, titel] of proReihe) {
|
||||||
|
if (titel.length < 2) continue;
|
||||||
|
if (!titel.every((eintrag) => gesehenTitel.has(eintrag.id))) continue;
|
||||||
|
reihen.set(id, titel.find((eintrag) => eintrag.name)?.name ?? "Reihe");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Abgeschlossene Universen ---
|
||||||
|
const abgeschlossene = new Map<string, string>();
|
||||||
|
let eigeneReihenfolge = false;
|
||||||
|
for (const universum of universen) {
|
||||||
|
if (universum.items.length === 0) continue;
|
||||||
|
if (!universum.items.every((item) => gesehenTitel.has(item.titleId))) continue;
|
||||||
|
abgeschlossene.set(universum.id, universum.name);
|
||||||
|
// „Chronologe": abgeschlossen, und die führende Reihenfolge ist eine
|
||||||
|
// selbst gebaute — nicht die berechnete Erscheinungsreihenfolge.
|
||||||
|
if (universum.orders.some((order) => order.kind === "CUSTOM" && order.isDefault)) {
|
||||||
|
eigeneReihenfolge = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
zeitzone,
|
||||||
|
eintraege: eintraege.length,
|
||||||
|
minutenGesamt,
|
||||||
|
episodenGesehen,
|
||||||
|
filmeGesehen,
|
||||||
|
bewertungen,
|
||||||
|
niedrigsteBewertung,
|
||||||
|
besitzt,
|
||||||
|
watchlist,
|
||||||
|
serienAbgeschlossen,
|
||||||
|
hoechsterDurchlauf,
|
||||||
|
genres,
|
||||||
|
jahrzehnte,
|
||||||
|
laengsteTreue,
|
||||||
|
minutenBesterTag,
|
||||||
|
nachtsGesehen,
|
||||||
|
hatPasskey: passkeys > 0,
|
||||||
|
hatMedienserverLauf: medienserver > 0,
|
||||||
|
reihen,
|
||||||
|
universen: abgeschlossene,
|
||||||
|
eigeneReihenfolge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalendertag und Stunde in der Zeitzone des Nutzers.
|
||||||
|
*
|
||||||
|
* Wichtig genug für eigene Helfer: beim Monatsdiagramm hat ein
|
||||||
|
* Zeitzonenversatz schon einmal für einen leeren Chart gesorgt. „Acht Stunden
|
||||||
|
* an einem Tag" und „nachts um vier" wären in der Serverzeitzone schlicht
|
||||||
|
* falsch.
|
||||||
|
*/
|
||||||
|
function tagIn(zeitpunkt: Date, zeitzone: string): string {
|
||||||
|
return new Intl.DateTimeFormat("sv-SE", {
|
||||||
|
timeZone: zeitzone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(zeitpunkt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stundeIn(zeitpunkt: Date, zeitzone: string): number {
|
||||||
|
// Über die Bestandteile statt über die formatierte Zeichenkette: auf Deutsch
|
||||||
|
// liefert Intl für eine einzelne Stunde „03 Uhr", und daraus wird beim
|
||||||
|
// Umwandeln in eine Zahl NaN. Ein Test hat genau das aufgedeckt.
|
||||||
|
const teile = new Intl.DateTimeFormat("de-DE", {
|
||||||
|
timeZone: zeitzone,
|
||||||
|
hour: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(zeitpunkt);
|
||||||
|
|
||||||
|
const stunde = teile.find((teil) => teil.type === "hour")?.value ?? "";
|
||||||
|
const zahl = Number.parseInt(stunde, 10);
|
||||||
|
return Number.isFinite(zahl) ? zahl : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Der Katalog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Regel = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
beschreibung: string;
|
||||||
|
symbol: SymbolName;
|
||||||
|
gruppe: Gruppe;
|
||||||
|
erfuellt: (daten: Datensatz) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KATALOG: Regel[] = [
|
||||||
|
// --- Anfänge ---
|
||||||
|
{
|
||||||
|
key: "erstkontakt",
|
||||||
|
name: "Erstkontakt",
|
||||||
|
beschreibung: "Den ersten Titel in die Bibliothek aufgenommen",
|
||||||
|
symbol: "spross",
|
||||||
|
gruppe: "anfaenge",
|
||||||
|
erfuellt: (d) => d.eintraege >= 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "abgehakt",
|
||||||
|
name: "Abgehakt",
|
||||||
|
beschreibung: "Die erste Serie vollständig durchgeschaut",
|
||||||
|
symbol: "haken",
|
||||||
|
gruppe: "anfaenge",
|
||||||
|
erfuellt: (d) => d.serienAbgeschlossen.length >= 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "schluesselmeister",
|
||||||
|
name: "Schlüsselmeister",
|
||||||
|
beschreibung: "Einen Passkey angelegt",
|
||||||
|
symbol: "schluessel",
|
||||||
|
gruppe: "anfaenge",
|
||||||
|
erfuellt: (d) => d.hatPasskey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "heimkehrer",
|
||||||
|
name: "Heimkehrer",
|
||||||
|
beschreibung: "Den eigenen Medienserver angebunden und abgeglichen",
|
||||||
|
symbol: "haus",
|
||||||
|
gruppe: "anfaenge",
|
||||||
|
erfuellt: (d) => d.hatMedienserverLauf,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Reihen und Universen (dynamische kommen weiter unten dazu) ---
|
||||||
|
{
|
||||||
|
key: "chronologe",
|
||||||
|
name: "Chronologe",
|
||||||
|
beschreibung: "Ein Universum in einer selbst gebauten Reihenfolge durchlaufen",
|
||||||
|
symbol: "kompass",
|
||||||
|
gruppe: "reihen",
|
||||||
|
erfuellt: (d) => d.eigeneReihenfolge,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Ausdauer ---
|
||||||
|
{
|
||||||
|
key: "serientaeter-1000",
|
||||||
|
name: "Serientäter",
|
||||||
|
beschreibung: "1000 Episoden gesehen",
|
||||||
|
symbol: "bildschirm",
|
||||||
|
gruppe: "ausdauer",
|
||||||
|
erfuellt: (d) => d.episodenGesehen >= 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vielseher-100",
|
||||||
|
name: "Vielseher",
|
||||||
|
beschreibung: "100 Filme gesehen",
|
||||||
|
symbol: "filmrolle",
|
||||||
|
gruppe: "ausdauer",
|
||||||
|
erfuellt: (d) => d.filmeGesehen >= 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ein-ganzer-tag",
|
||||||
|
name: "Ein ganzer Tag",
|
||||||
|
beschreibung: "24 Stunden Schauzeit insgesamt",
|
||||||
|
symbol: "sonne",
|
||||||
|
gruppe: "ausdauer",
|
||||||
|
erfuellt: (d) => d.minutenGesamt >= 24 * 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "eine-ganze-woche",
|
||||||
|
name: "Eine ganze Woche",
|
||||||
|
beschreibung: "168 Stunden Schauzeit insgesamt",
|
||||||
|
symbol: "kalender",
|
||||||
|
gruppe: "ausdauer",
|
||||||
|
erfuellt: (d) => d.minutenGesamt >= 7 * 24 * 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "marathon",
|
||||||
|
name: "Marathon",
|
||||||
|
beschreibung: "Acht Stunden an einem Tag",
|
||||||
|
symbol: "laeufer",
|
||||||
|
gruppe: "ausdauer",
|
||||||
|
erfuellt: (d) => d.minutenBesterTag >= 8 * 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "langer-atem",
|
||||||
|
name: "Der lange Atem",
|
||||||
|
beschreibung: "Eine Serie mit mindestens 100 Episoden komplett gesehen",
|
||||||
|
symbol: "berg",
|
||||||
|
gruppe: "ausdauer",
|
||||||
|
erfuellt: (d) => d.serienAbgeschlossen.some((serie) => serie.episoden >= 100),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "treu-geblieben",
|
||||||
|
name: "Treu geblieben",
|
||||||
|
beschreibung: "Über ein Jahr an demselben Titel drangeblieben",
|
||||||
|
symbol: "herz",
|
||||||
|
gruppe: "ausdauer",
|
||||||
|
erfuellt: (d) => d.laengsteTreue >= 365,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "wiederholungstaeter",
|
||||||
|
name: "Wiederholungstäter",
|
||||||
|
beschreibung: "Einen Titel im dritten Durchlauf",
|
||||||
|
symbol: "kreis",
|
||||||
|
gruppe: "ausdauer",
|
||||||
|
erfuellt: (d) => d.hoechsterDurchlauf >= 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Breite ---
|
||||||
|
{
|
||||||
|
key: "feinschmecker",
|
||||||
|
name: "Feinschmecker",
|
||||||
|
beschreibung: "Titel aus zehn verschiedenen Genres gesehen",
|
||||||
|
symbol: "maske",
|
||||||
|
gruppe: "breite",
|
||||||
|
erfuellt: (d) => d.genres.size >= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "zeitreisender",
|
||||||
|
name: "Zeitreisender",
|
||||||
|
beschreibung: "Titel aus sieben Jahrzehnten gesehen",
|
||||||
|
symbol: "sanduhr",
|
||||||
|
gruppe: "breite",
|
||||||
|
erfuellt: (d) => d.jahrzehnte.size >= 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kritiker-50",
|
||||||
|
name: "Kritiker",
|
||||||
|
beschreibung: "50 Bewertungen abgegeben",
|
||||||
|
symbol: "stern",
|
||||||
|
gruppe: "breite",
|
||||||
|
erfuellt: (d) => d.bewertungen >= 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "strenges-urteil",
|
||||||
|
name: "Strenges Urteil",
|
||||||
|
beschreibung: "Einmal die Note 1 vergeben",
|
||||||
|
symbol: "salz",
|
||||||
|
gruppe: "breite",
|
||||||
|
erfuellt: (d) => d.niedrigsteBewertung === 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Ordnung ---
|
||||||
|
{
|
||||||
|
key: "sammler-25",
|
||||||
|
name: "Sammler",
|
||||||
|
beschreibung: "25 Titel in der eigenen Sammlung",
|
||||||
|
symbol: "scheibe",
|
||||||
|
gruppe: "ordnung",
|
||||||
|
erfuellt: (d) => d.besitzt >= 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "aufgeraeumt",
|
||||||
|
name: "Aufgeräumt",
|
||||||
|
beschreibung: "Die Watchlist vollständig abgearbeitet — bei mindestens 20 Titeln",
|
||||||
|
symbol: "besen",
|
||||||
|
gruppe: "ordnung",
|
||||||
|
// Nur sinnvoll, wenn überhaupt etwas da war: die Bibliothek muss die 20
|
||||||
|
// hergeben, die Watchlist aber leer sein.
|
||||||
|
erfuellt: (d) => d.watchlist === 0 && d.eintraege >= 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "durchgemacht",
|
||||||
|
name: "Durchgemacht",
|
||||||
|
beschreibung: "Zwischen drei und fünf Uhr morgens etwas abgehakt",
|
||||||
|
symbol: "mond",
|
||||||
|
gruppe: "ordnung",
|
||||||
|
erfuellt: (d) => d.nachtsGesehen,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Alle Abzeichen, die dieser Datensatz hergibt — feste und dynamische. */
|
||||||
|
function erreichte(daten: Datensatz): { key: string; label?: string }[] {
|
||||||
|
const treffer: { key: string; label?: string }[] = KATALOG.filter((regel) =>
|
||||||
|
regel.erfuellt(daten),
|
||||||
|
).map((regel) => ({ key: regel.key }));
|
||||||
|
|
||||||
|
// Reihen: je vollständig gesehener TMDB-Reihe ein eigenes Abzeichen. Der
|
||||||
|
// Name wird eingefroren, damit er später nicht wandert.
|
||||||
|
// Eingefroren wird der **TMDB-Name** der Reihe, nicht der Anzeigename. Der
|
||||||
|
// kuratierte Name wird erst beim Anzeigen aufgesetzt — so lässt er sich
|
||||||
|
// später ändern, ohne gespeicherte Abzeichen anzufassen.
|
||||||
|
for (const [id, name] of daten.reihen) {
|
||||||
|
treffer.push({ key: `reihe:${id}`, label: name });
|
||||||
|
}
|
||||||
|
for (const [id, name] of daten.universen) {
|
||||||
|
treffer.push({ key: `universum:${id}`, label: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return treffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auswerten
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Ergebnis = {
|
||||||
|
/** Neu vergebene Abzeichen — leer, wenn sich nichts geändert hat. */
|
||||||
|
neu: { key: string; label: string | null }[];
|
||||||
|
/** Beim allerersten Lauf eines Kontos wird nichts gemeldet. */
|
||||||
|
stumm: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wertet aus und vergibt, was fehlt.
|
||||||
|
*
|
||||||
|
* Der **erste Lauf eines Kontos ist stumm**: bestehende Bibliotheken bringen
|
||||||
|
* Jahre an Daten mit, und zwei Dutzend Benachrichtigungen auf einmal wären
|
||||||
|
* keine Freude, sondern Lärm.
|
||||||
|
*/
|
||||||
|
export async function evaluateAchievements(userId: string): Promise<Ergebnis> {
|
||||||
|
const [daten, vorhanden] = await Promise.all([
|
||||||
|
sammle(userId),
|
||||||
|
prisma.achievement.findMany({ where: { userId }, select: { key: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bekannt = new Set(vorhanden.map((eintrag) => eintrag.key));
|
||||||
|
const erster = vorhanden.length === 0;
|
||||||
|
|
||||||
|
const fehlend = erreichte(daten).filter((eintrag) => !bekannt.has(eintrag.key));
|
||||||
|
if (fehlend.length === 0) return { neu: [], stumm: erster };
|
||||||
|
|
||||||
|
await prisma.achievement.createMany({
|
||||||
|
data: fehlend.map((eintrag) => ({ userId, key: eintrag.key, label: eintrag.label ?? null })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
neu: erster ? [] : fehlend.map((eintrag) => ({ key: eintrag.key, label: eintrag.label ?? null })),
|
||||||
|
stumm: erster,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wertet aus, falls für dieses Konto noch nie ausgewertet wurde.
|
||||||
|
*
|
||||||
|
* Gedacht für den ersten Aufruf der Übersicht: ohne diesen Anstoß bliebe sie
|
||||||
|
* bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt. Ein
|
||||||
|
* Konto durchläuft das genau einmal — danach kostet es eine Zählabfrage.
|
||||||
|
*/
|
||||||
|
export async function ensureAchievementsEvaluated(userId: string): Promise<void> {
|
||||||
|
const vorhanden = await prisma.achievement.count({ where: { userId } });
|
||||||
|
if (vorhanden > 0) return;
|
||||||
|
await evaluateAchievements(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Für den täglichen Lauf: alle Konten durchgehen. */
|
||||||
|
export async function evaluateAllAchievements(): Promise<{ konten: number; vergeben: number }> {
|
||||||
|
const konten = await prisma.user.findMany({ where: { isActive: true }, select: { id: true } });
|
||||||
|
|
||||||
|
let vergeben = 0;
|
||||||
|
for (const konto of konten) {
|
||||||
|
try {
|
||||||
|
const ergebnis = await evaluateAchievements(konto.id);
|
||||||
|
vergeben += ergebnis.neu.length;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Errungenschaften konnten nicht ausgewertet werden:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { konten: konten.length, vergeben };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Anzeigen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alles für die Übersicht: erreichte Abzeichen mit Datum, dazu die offenen
|
||||||
|
* mit ihrer Bedingung. Die offenen zu zeigen ist Absicht — sie sind die
|
||||||
|
* Landkarte, nicht die Peitsche.
|
||||||
|
*/
|
||||||
|
export async function listAchievements(userId: string): Promise<AchievementView[]> {
|
||||||
|
const vergeben = await prisma.achievement.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { key: true, label: true, unlockedAt: true },
|
||||||
|
orderBy: { unlockedAt: "desc" },
|
||||||
|
});
|
||||||
|
const nachKey = new Map(vergeben.map((eintrag) => [eintrag.key, eintrag]));
|
||||||
|
|
||||||
|
const feste: AchievementView[] = KATALOG.map((regel) => ({
|
||||||
|
key: regel.key,
|
||||||
|
name: regel.name,
|
||||||
|
beschreibung: regel.beschreibung,
|
||||||
|
symbol: regel.symbol,
|
||||||
|
gruppe: regel.gruppe,
|
||||||
|
unlockedAt: nachKey.get(regel.key)?.unlockedAt ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Dynamische gibt es nur, wenn sie erreicht wurden — eine Liste aller
|
||||||
|
// denkbaren Filmreihen wäre sinnlos lang.
|
||||||
|
const dynamisch: AchievementView[] = vergeben
|
||||||
|
.filter((eintrag) => eintrag.key.includes(":"))
|
||||||
|
.map((eintrag) => {
|
||||||
|
const istReihe = eintrag.key.startsWith("reihe:");
|
||||||
|
const id = Number(eintrag.key.slice("reihe:".length));
|
||||||
|
const kuratiert = istReihe ? kuratierteReihe(id, eintrag.label) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: eintrag.key,
|
||||||
|
name: kuratiert?.name ?? eintrag.label ?? (istReihe ? "Reihe komplett" : "Universum vollendet"),
|
||||||
|
beschreibung: istReihe
|
||||||
|
? `Alle Filme der Reihe „${eintrag.label ?? "…"}" gesehen`
|
||||||
|
: `Alle Titel des Universums „${eintrag.label ?? "…"}" abgeschlossen`,
|
||||||
|
symbol: (kuratiert?.symbol ?? (istReihe ? "klappe" : "planet")) as SymbolName,
|
||||||
|
gruppe: "reihen" as Gruppe,
|
||||||
|
unlockedAt: eintrag.unlockedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...feste, ...dynamisch];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzeigename eines Abzeichens aus Schlüssel und eingefrorenem Namen.
|
||||||
|
* Wird für die Einblendung direkt nach dem Erreichen gebraucht, wo noch keine
|
||||||
|
* vollständige Liste geholt wurde.
|
||||||
|
*/
|
||||||
|
export function achievementName(key: string, label: string | null): string {
|
||||||
|
if (key.startsWith("reihe:")) {
|
||||||
|
const id = Number(key.slice("reihe:".length));
|
||||||
|
return kuratierteReihe(id, label)?.name ?? `Reihe komplett — ${label ?? "…"}`;
|
||||||
|
}
|
||||||
|
if (key.startsWith("universum:")) return `Universum vollendet — ${label ?? "…"}`;
|
||||||
|
return KATALOG.find((regel) => regel.key === key)?.name ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur die erreichten — für das öffentliche Profil. */
|
||||||
|
export async function listUnlockedAchievements(userId: string): Promise<AchievementView[]> {
|
||||||
|
return (await listAchievements(userId)).filter((abzeichen) => abzeichen.unlockedAt !== null);
|
||||||
|
}
|
||||||
@@ -81,6 +81,41 @@ export const env = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
passkeys: {
|
||||||
|
/**
|
||||||
|
* Passkeys lassen sich abschalten wie OIDC. Standardmäßig an — sie
|
||||||
|
* kosten nichts, solange niemand einen anlegt.
|
||||||
|
*/
|
||||||
|
enabled: bool("ENABLE_PASSKEYS", true),
|
||||||
|
/**
|
||||||
|
* Die RP ID ist der Hostname, an den ein Passkey gebunden ist. Sie kommt
|
||||||
|
* aus APP_URL und **nicht** aus dem Host-Header: hinter einem Reverse
|
||||||
|
* Proxy hinge sie sonst am falschen Namen, und alle Passkeys wären
|
||||||
|
* plötzlich ungültig.
|
||||||
|
*/
|
||||||
|
get rpId(): string {
|
||||||
|
try {
|
||||||
|
return new URL(env.appUrl).hostname;
|
||||||
|
} catch {
|
||||||
|
return "localhost";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Der Browser verlangt einen sicheren Kontext. Erlaubt sind HTTPS und
|
||||||
|
* localhost; eine nackte IP-Adresse ist als RP ID ohnehin unzulässig.
|
||||||
|
*/
|
||||||
|
get isAvailable(): boolean {
|
||||||
|
if (!this.enabled) return false;
|
||||||
|
try {
|
||||||
|
const url = new URL(env.appUrl);
|
||||||
|
if (url.protocol === "https:") return true;
|
||||||
|
return url.hostname === "localhost" || url.hostname.endsWith(".localhost");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: optional("OIDC_ISSUER"),
|
issuer: optional("OIDC_ISSUER"),
|
||||||
clientId: optional("OIDC_CLIENT_ID"),
|
clientId: optional("OIDC_CLIENT_ID"),
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client für Jellyfin (und Emby — die API ist an den hier benutzten Stellen
|
||||||
|
* dieselbe).
|
||||||
|
*
|
||||||
|
* Zwei Dinge, die beim Bauen Zeit gekostet hätten, wenn man sie nicht vorher
|
||||||
|
* weiß:
|
||||||
|
*
|
||||||
|
* 1. **Mit einem reinen API-Key fehlt `UserData`.** Man bekommt den Bestand,
|
||||||
|
* aber ohne Gesehen-Status — und ohne Fehlermeldung. Deshalb meldet sich
|
||||||
|
* Flimmer über `AuthenticateByName` an und prüft beim Verbinden
|
||||||
|
* ausdrücklich nach, ob `UserData` ankommt.
|
||||||
|
* 2. Jellyfin erwartet einen `Authorization`-Kopf in einer eigenen Form
|
||||||
|
* (`MediaBrowser Client=…`), nicht `Bearer`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class JellyfinError extends Error {}
|
||||||
|
|
||||||
|
/** Ein Item, so weit Flimmer es braucht. */
|
||||||
|
export type JellyfinItem = {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
Type: "Movie" | "Episode" | "Series" | string;
|
||||||
|
ProviderIds?: Record<string, string> | null;
|
||||||
|
/** Bei Episoden: die Serie darüber. */
|
||||||
|
SeriesId?: string | null;
|
||||||
|
SeriesName?: string | null;
|
||||||
|
/** Staffel- und Episodennummer. */
|
||||||
|
ParentIndexNumber?: number | null;
|
||||||
|
IndexNumber?: number | null;
|
||||||
|
ProductionYear?: number | null;
|
||||||
|
RunTimeTicks?: number | null;
|
||||||
|
UserData?: {
|
||||||
|
Played?: boolean;
|
||||||
|
PlayCount?: number;
|
||||||
|
LastPlayedDate?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JellyfinUser = { Id: string; Name: string };
|
||||||
|
|
||||||
|
type ClientOptions = { baseUrl: string; token: string };
|
||||||
|
|
||||||
|
/** Der Kopf, den Jellyfin zur Identifikation erwartet. */
|
||||||
|
function authHeader(token?: string): string {
|
||||||
|
const teile = [
|
||||||
|
'Client="Flimmer"',
|
||||||
|
`Device="${env.appName.replace(/"/g, "")}"`,
|
||||||
|
'DeviceId="flimmer-sync"',
|
||||||
|
'Version="1.0"',
|
||||||
|
];
|
||||||
|
if (token) teile.push(`Token="${token}"`);
|
||||||
|
return `MediaBrowser ${teile.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
|
return baseUrl.trim().replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
{ baseUrl, token }: ClientOptions,
|
||||||
|
pfad: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${normalizeBaseUrl(baseUrl)}${pfad}`;
|
||||||
|
|
||||||
|
let antwort: Response;
|
||||||
|
try {
|
||||||
|
antwort = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
authorization: authHeader(token),
|
||||||
|
...(init.body ? { "content-type": "application/json" } : {}),
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
// Der Bestand kann groß sein und der Server steht oft im selben Netz —
|
||||||
|
// trotzdem nicht ewig warten.
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const grund = error instanceof Error ? error.message : "unbekannt";
|
||||||
|
throw new JellyfinError(`Der Server ist nicht erreichbar (${grund}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (antwort.status === 401) {
|
||||||
|
throw new JellyfinError("Die Anmeldedaten wurden abgelehnt.");
|
||||||
|
}
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new JellyfinError(`Der Server antwortete mit ${antwort.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (antwort.status === 204) return undefined as T;
|
||||||
|
return (await antwort.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmelden mit Benutzername und Passwort.
|
||||||
|
*
|
||||||
|
* Bewusst nicht mit einem API-Key: der liefert keine `UserData` und damit
|
||||||
|
* keinen Gesehen-Status.
|
||||||
|
*/
|
||||||
|
export async function authenticate(
|
||||||
|
baseUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ token: string; user: JellyfinUser; serverName: string }> {
|
||||||
|
const antwort = await request<{
|
||||||
|
AccessToken: string;
|
||||||
|
User: JellyfinUser;
|
||||||
|
ServerId?: string;
|
||||||
|
}>(
|
||||||
|
{ baseUrl, token: "" },
|
||||||
|
"/Users/AuthenticateByName",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ Username: username, Pw: password }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!antwort.AccessToken) throw new JellyfinError("Der Server hat kein Zugangstoken geliefert.");
|
||||||
|
|
||||||
|
const info = await systemInfo(baseUrl, antwort.AccessToken).catch(() => null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: antwort.AccessToken,
|
||||||
|
user: antwort.User,
|
||||||
|
serverName: info?.ServerName ?? "Jellyfin",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function systemInfo(baseUrl: string, token: string) {
|
||||||
|
return request<{ ServerName?: string; Version?: string }>(
|
||||||
|
{ baseUrl, token },
|
||||||
|
"/System/Info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestand und Sichtungen eines Nutzers.
|
||||||
|
*
|
||||||
|
* `enableUserData=true` ist der entscheidende Parameter — ohne ihn (oder mit
|
||||||
|
* einem API-Key statt eines Nutzertokens) fehlt der Gesehen-Status.
|
||||||
|
*/
|
||||||
|
export async function fetchItems(
|
||||||
|
baseUrl: string,
|
||||||
|
token: string,
|
||||||
|
serverUserId: string,
|
||||||
|
{ startIndex = 0, limit = 500 }: { startIndex?: number; limit?: number } = {},
|
||||||
|
): Promise<{ items: JellyfinItem[]; total: number }> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
userId: serverUserId,
|
||||||
|
Recursive: "true",
|
||||||
|
IncludeItemTypes: "Movie,Episode",
|
||||||
|
Fields: "ProviderIds,ParentId",
|
||||||
|
enableUserData: "true",
|
||||||
|
EnableImages: "false",
|
||||||
|
StartIndex: String(startIndex),
|
||||||
|
Limit: String(limit),
|
||||||
|
SortBy: "Id",
|
||||||
|
SortOrder: "Ascending",
|
||||||
|
});
|
||||||
|
|
||||||
|
const antwort = await request<{ Items?: JellyfinItem[]; TotalRecordCount?: number }>(
|
||||||
|
{ baseUrl, token },
|
||||||
|
`/Items?${params.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items: antwort.Items ?? [], total: antwort.TotalRecordCount ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopfdaten einer Serie — dort hängt die TMDB-ID, nicht an der Episode. */
|
||||||
|
export async function fetchSeries(
|
||||||
|
baseUrl: string,
|
||||||
|
token: string,
|
||||||
|
serverUserId: string,
|
||||||
|
seriesIds: string[],
|
||||||
|
): Promise<Map<string, JellyfinItem>> {
|
||||||
|
const treffer = new Map<string, JellyfinItem>();
|
||||||
|
if (seriesIds.length === 0) return treffer;
|
||||||
|
|
||||||
|
// In Blöcken abfragen: eine Adresse mit hunderten Kennungen wird sonst zu lang.
|
||||||
|
for (let start = 0; start < seriesIds.length; start += 50) {
|
||||||
|
const block = seriesIds.slice(start, start + 50);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
userId: serverUserId,
|
||||||
|
Ids: block.join(","),
|
||||||
|
Fields: "ProviderIds",
|
||||||
|
EnableImages: "false",
|
||||||
|
});
|
||||||
|
|
||||||
|
const antwort = await request<{ Items?: JellyfinItem[] }>(
|
||||||
|
{ baseUrl, token },
|
||||||
|
`/Items?${params.toString()}`,
|
||||||
|
);
|
||||||
|
for (const item of antwort.Items ?? []) treffer.set(item.Id, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return treffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Verbindung — und ob der Gesehen-Status tatsächlich mitkommt.
|
||||||
|
*
|
||||||
|
* Genau hier liegt der Fallstrick: mit einem API-Key liefert Jellyfin den
|
||||||
|
* Bestand ohne `UserData`. Ohne diese Prüfung liefe der Abgleich stumm ins
|
||||||
|
* Leere und niemand wüsste, warum nichts als gesehen ankommt.
|
||||||
|
*/
|
||||||
|
export async function checkConnection(
|
||||||
|
baseUrl: string,
|
||||||
|
token: string,
|
||||||
|
serverUserId: string,
|
||||||
|
): Promise<{
|
||||||
|
serverName: string;
|
||||||
|
version: string | null;
|
||||||
|
itemCount: number;
|
||||||
|
hasUserData: boolean;
|
||||||
|
withTmdbId: number;
|
||||||
|
}> {
|
||||||
|
const info = await systemInfo(baseUrl, token);
|
||||||
|
const { items, total } = await fetchItems(baseUrl, token, serverUserId, { limit: 50 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverName: info.ServerName ?? "Jellyfin",
|
||||||
|
version: info.Version ?? null,
|
||||||
|
itemCount: total,
|
||||||
|
hasUserData: items.length === 0 || items.some((item) => item.UserData != null),
|
||||||
|
withTmdbId: items.filter((item) => tmdbIdOf(item) != null).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die TMDB-ID aus den ProviderIds — Jellyfin schreibt den Schlüssel uneinheitlich. */
|
||||||
|
export function tmdbIdOf(item: JellyfinItem): number | null {
|
||||||
|
const ids = item.ProviderIds ?? {};
|
||||||
|
const roh = ids.Tmdb ?? ids.TMDB ?? ids.tmdb ?? ids.TmdbId;
|
||||||
|
if (!roh) return null;
|
||||||
|
const zahl = Number.parseInt(String(roh), 10);
|
||||||
|
return Number.isFinite(zahl) && zahl > 0 ? zahl : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Läuft der Server unter dieser Adresse überhaupt? */
|
||||||
|
export async function listUsers(baseUrl: string, token: string): Promise<JellyfinUser[]> {
|
||||||
|
return request<JellyfinUser[]>({ baseUrl, token }, "/Users");
|
||||||
|
}
|
||||||
@@ -338,6 +338,27 @@ export async function markMovieWatched(
|
|||||||
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" };
|
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt die Sichtung eines Films im aktuellen Durchlauf zurück.
|
||||||
|
*
|
||||||
|
* Das Gegenstück zu `markMovieWatched()` — und das Pendant zu
|
||||||
|
* `unmarkEpisodes()` bei Serien.
|
||||||
|
*
|
||||||
|
* Die Eingrenzung auf `run` ist wesentlich: wer einen Film dreimal gesehen
|
||||||
|
* hat, hat drei Sichtungen. Ohne `run` löschte das Zurücknehmen alle drei und
|
||||||
|
* verfälschte rückwirkend die Statistik. Zurückgenommen wird nur, was im
|
||||||
|
* laufenden Durchlauf abgehakt wurde.
|
||||||
|
*/
|
||||||
|
export async function unmarkMovie(userId: string, entryId: string) {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
if (entry.title.type !== "MOVIE") throw new LibraryError("Das ist keine Filmsichtung.");
|
||||||
|
|
||||||
|
await prisma.watch.deleteMany({
|
||||||
|
where: { userId, entryId, episodeId: null, run: entry.currentRun },
|
||||||
|
});
|
||||||
|
return reconcileStatus(userId, entryId);
|
||||||
|
}
|
||||||
|
|
||||||
/** Macht eine Sichtungsaktion rückgängig. */
|
/** Macht eine Sichtungsaktion rückgängig. */
|
||||||
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
|
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
|
||||||
await requireEntry(userId, entryId);
|
await requireEntry(userId, entryId);
|
||||||
|
|||||||
@@ -0,0 +1,642 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { ensureTitle } from "./catalog";
|
||||||
|
import { decryptSecret, encryptSecret } from "./secrets";
|
||||||
|
import {
|
||||||
|
authenticate,
|
||||||
|
checkConnection,
|
||||||
|
fetchItems,
|
||||||
|
fetchSeries,
|
||||||
|
JellyfinError,
|
||||||
|
tmdbIdOf,
|
||||||
|
type JellyfinItem,
|
||||||
|
} from "./jellyfin";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abgleich mit einem Medienserver.
|
||||||
|
*
|
||||||
|
* Grundhaltung: **Einbahnstraße.** Der Abgleich ergänzt, was auf dem Server
|
||||||
|
* liegt oder dort gelaufen ist — er löscht nie etwas, das in Flimmer steht.
|
||||||
|
* Wer einen Film in Flimmer als gesehen markiert und ihn dann in Jellyfin
|
||||||
|
* löscht, verliert seine Sichtung nicht.
|
||||||
|
*
|
||||||
|
* Zugeordnet wird über die **TMDB-ID**, die Jellyfin in `ProviderIds`
|
||||||
|
* mitliefert — dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut.
|
||||||
|
* Eine unscharfe Suche über Titel und Jahr ist damit nicht nötig, und die
|
||||||
|
* Zuordnung ist entweder eindeutig oder sie findet nicht statt.
|
||||||
|
*
|
||||||
|
* **Grenze der Quelle:** Jellyfin kennt `PlayCount` und `LastPlayedDate`, aber
|
||||||
|
* keine Liste einzelner Sichtungen. Rewatch-Durchläufe lassen sich daraus
|
||||||
|
* nicht rekonstruieren — übernommen wird der letzte Termin. Ein dreimal
|
||||||
|
* geschauter Film erscheint in der Statistik mit einem Termin, nicht mit drei.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class MediaServerError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Titel, den der Abgleich nicht zuordnen konnte.
|
||||||
|
*
|
||||||
|
* Zusammengefasst je TMDB-Kennung: Jellyfin führt oft mehrere Dateien zum
|
||||||
|
* selben Film (Extended Edition, zweite Fassung), und in einer Auswahlliste
|
||||||
|
* will niemand denselben Titel dreimal anhaken.
|
||||||
|
*/
|
||||||
|
export type UnassignedItem = {
|
||||||
|
/** Fehlt sie, lässt sich der Titel nicht automatisch holen. */
|
||||||
|
tmdbId: number | null;
|
||||||
|
typ: "SERIES" | "MOVIE" | null;
|
||||||
|
name: string;
|
||||||
|
grund: string;
|
||||||
|
/** Wie viele Jellyfin-Dateien auf diesen Titel zeigen. */
|
||||||
|
dateien: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncReport = {
|
||||||
|
gelesen: number;
|
||||||
|
bestandGesetzt: number;
|
||||||
|
sichtungenAngelegt: number;
|
||||||
|
aufgenommen: number;
|
||||||
|
/** Aus TMDB nachgeladen — nur über die Auswahl, nie von selbst. */
|
||||||
|
nachgeladen: number;
|
||||||
|
nichtZugeordnet: UnassignedItem[];
|
||||||
|
/** Wurde die Liste gekürzt? Dann steht hier, wie viele fehlen. */
|
||||||
|
weitereNichtZugeordnet: number;
|
||||||
|
fehler: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEERER_BERICHT = (): SyncReport => ({
|
||||||
|
gelesen: 0,
|
||||||
|
bestandGesetzt: 0,
|
||||||
|
sichtungenAngelegt: 0,
|
||||||
|
aufgenommen: 0,
|
||||||
|
nachgeladen: 0,
|
||||||
|
nichtZugeordnet: [],
|
||||||
|
weitereNichtZugeordnet: 0,
|
||||||
|
fehler: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obergrenze der Auswahlliste.
|
||||||
|
*
|
||||||
|
* Früher standen hier 50 — gedacht als Schutz vor Lärm, tatsächlich ein Blick
|
||||||
|
* durchs Schlüsselloch: bei über tausend gelesenen Einträgen sah man den
|
||||||
|
* Bruchteil. Jetzt großzügig, aber nicht unbegrenzt: ein Server, der
|
||||||
|
* Unsinn liefert, soll den Bericht nicht ins Unermessliche treiben. Was
|
||||||
|
* darüber hinausgeht, wird gezählt und benannt.
|
||||||
|
*/
|
||||||
|
const BERICHT_GRENZE = 1000;
|
||||||
|
|
||||||
|
/** Wie viele Titel ein Importlauf höchstens holt. */
|
||||||
|
const IMPORT_GRENZE = 100;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Verbindung einrichten
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function connectJellyfin(
|
||||||
|
userId: string,
|
||||||
|
input: { baseUrl: string; username: string; password: string },
|
||||||
|
) {
|
||||||
|
const baseUrl = input.baseUrl.trim().replace(/\/+$/, "");
|
||||||
|
if (!/^https?:\/\//i.test(baseUrl)) {
|
||||||
|
throw new MediaServerError("Die Adresse muss mit http:// oder https:// beginnen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let angemeldet;
|
||||||
|
try {
|
||||||
|
angemeldet = await authenticate(baseUrl, input.username, input.password);
|
||||||
|
} catch (error) {
|
||||||
|
throw new MediaServerError(
|
||||||
|
error instanceof JellyfinError ? error.message : "Die Anmeldung ist fehlgeschlagen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruefung = await checkConnection(baseUrl, angemeldet.token, angemeldet.user.Id).catch(
|
||||||
|
(error) => {
|
||||||
|
throw new MediaServerError(
|
||||||
|
error instanceof JellyfinError ? error.message : "Der Server antwortet nicht wie erwartet.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const verbindung = await prisma.mediaServerConnection.upsert({
|
||||||
|
where: { userId_baseUrl: { userId, baseUrl } },
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
baseUrl,
|
||||||
|
kind: "JELLYFIN",
|
||||||
|
name: angemeldet.serverName,
|
||||||
|
token: encryptSecret(angemeldet.token),
|
||||||
|
serverUserId: angemeldet.user.Id,
|
||||||
|
serverUserName: angemeldet.user.Name,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: angemeldet.serverName,
|
||||||
|
token: encryptSecret(angemeldet.token),
|
||||||
|
serverUserId: angemeldet.user.Id,
|
||||||
|
serverUserName: angemeldet.user.Name,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, baseUrl: true, serverUserName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { verbindung, pruefung };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listConnections(userId: string) {
|
||||||
|
return prisma.mediaServerConnection.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
kind: true,
|
||||||
|
name: true,
|
||||||
|
baseUrl: true,
|
||||||
|
serverUserName: true,
|
||||||
|
enabled: true,
|
||||||
|
syncOwned: true,
|
||||||
|
syncWatched: true,
|
||||||
|
lastSyncAt: true,
|
||||||
|
lastSyncReport: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConnection(
|
||||||
|
userId: string,
|
||||||
|
connectionId: string,
|
||||||
|
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
|
||||||
|
) {
|
||||||
|
const treffer = await prisma.mediaServerConnection.updateMany({
|
||||||
|
where: { id: connectionId, userId },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (treffer.count === 0) throw new MediaServerError("Diese Verbindung gibt es nicht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConnection(userId: string, connectionId: string) {
|
||||||
|
const treffer = await prisma.mediaServerConnection.deleteMany({
|
||||||
|
where: { id: connectionId, userId },
|
||||||
|
});
|
||||||
|
if (treffer.count === 0) throw new MediaServerError("Diese Verbindung gibt es nicht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft eine bestehende Verbindung erneut — etwa nach einem Serverumzug. */
|
||||||
|
export async function testConnection(userId: string, connectionId: string) {
|
||||||
|
const verbindung = await requireConnection(userId, connectionId);
|
||||||
|
try {
|
||||||
|
return await checkConnection(
|
||||||
|
verbindung.baseUrl,
|
||||||
|
decryptSecret(verbindung.token),
|
||||||
|
verbindung.serverUserId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new MediaServerError(
|
||||||
|
error instanceof JellyfinError ? error.message : "Der Server antwortet nicht wie erwartet.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireConnection(userId: string, connectionId: string) {
|
||||||
|
const verbindung = await prisma.mediaServerConnection.findFirst({
|
||||||
|
where: { id: connectionId, userId },
|
||||||
|
});
|
||||||
|
if (!verbindung) throw new MediaServerError("Diese Verbindung gibt es nicht.");
|
||||||
|
return verbindung;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Abgleich
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function syncConnection(
|
||||||
|
userId: string,
|
||||||
|
connectionId: string,
|
||||||
|
{ aufnehmen = false }: { aufnehmen?: boolean } = {},
|
||||||
|
): Promise<SyncReport> {
|
||||||
|
const verbindung = await requireConnection(userId, connectionId);
|
||||||
|
const bericht = LEERER_BERICHT();
|
||||||
|
|
||||||
|
const token = decryptSecret(verbindung.token);
|
||||||
|
const items = await alleItems(verbindung.baseUrl, token, verbindung.serverUserId);
|
||||||
|
bericht.gelesen = items.length;
|
||||||
|
|
||||||
|
// Episoden tragen die TMDB-ID nicht selbst — die hängt an der Serie darüber.
|
||||||
|
const serienIds = [...new Set(items.map((item) => item.SeriesId).filter((id): id is string => Boolean(id)))];
|
||||||
|
const serien = await fetchSeries(verbindung.baseUrl, token, verbindung.serverUserId, serienIds);
|
||||||
|
|
||||||
|
// Alles einmal vorladen statt je Item zu fragen.
|
||||||
|
const tmdbIds = new Set<number>();
|
||||||
|
for (const item of items) {
|
||||||
|
const id = item.Type === "Episode" ? tmdbIdOfEpisode(item, serien) : tmdbIdOf(item);
|
||||||
|
if (id != null) tmdbIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titel = await prisma.title.findMany({
|
||||||
|
where: { tmdbId: { in: [...tmdbIds] } },
|
||||||
|
select: { id: true, tmdbId: true, type: true, title: true, runtime: true },
|
||||||
|
});
|
||||||
|
const titelNachTmdb = new Map(titel.map((eintrag) => [eintrag.tmdbId!, eintrag]));
|
||||||
|
|
||||||
|
const eintraege = await prisma.libraryEntry.findMany({
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
select: { id: true, titleId: true, currentRun: true, owned: true },
|
||||||
|
});
|
||||||
|
const eintragNachTitel = new Map(eintraege.map((eintrag) => [eintrag.titleId, eintrag]));
|
||||||
|
|
||||||
|
// Zusammengefasst je Titel, nicht je Datei.
|
||||||
|
const gesammelt = new Map<string, UnassignedItem>();
|
||||||
|
const bestandTitelIds = new Set<string>();
|
||||||
|
const gesehen: { titleId: string; episodeNumber: number | null; seasonNumber: number | null; wann: Date; externalId: string }[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const istEpisode = item.Type === "Episode";
|
||||||
|
const tmdbId = istEpisode ? tmdbIdOfEpisode(item, serien) : tmdbIdOf(item);
|
||||||
|
|
||||||
|
if (tmdbId == null) {
|
||||||
|
merkeUnzugeordnet(bericht, gesammelt, item, null, "keine TMDB-Kennung am Item");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const treffer = titelNachTmdb.get(tmdbId);
|
||||||
|
if (!treffer) {
|
||||||
|
merkeUnzugeordnet(bericht, gesammelt, item, tmdbId, "noch nicht im Katalog");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bestandTitelIds.add(treffer.id);
|
||||||
|
|
||||||
|
if (item.UserData?.Played) {
|
||||||
|
gesehen.push({
|
||||||
|
titleId: treffer.id,
|
||||||
|
seasonNumber: istEpisode ? (item.ParentIndexNumber ?? null) : null,
|
||||||
|
episodeNumber: istEpisode ? (item.IndexNumber ?? null) : null,
|
||||||
|
wann: item.UserData.LastPlayedDate ? new Date(item.UserData.LastPlayedDate) : new Date(),
|
||||||
|
externalId: item.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bestand ---
|
||||||
|
if (verbindung.syncOwned) {
|
||||||
|
for (const titleId of bestandTitelIds) {
|
||||||
|
const vorhanden = eintragNachTitel.get(titleId);
|
||||||
|
|
||||||
|
if (!vorhanden) {
|
||||||
|
if (!aufnehmen) continue;
|
||||||
|
const neu = await prisma.libraryEntry.create({
|
||||||
|
data: { userId, titleId, status: "WATCHLIST", owned: true, ownedSource: "Jellyfin" },
|
||||||
|
select: { id: true, titleId: true, currentRun: true, owned: true },
|
||||||
|
});
|
||||||
|
eintragNachTitel.set(titleId, neu);
|
||||||
|
bericht.aufgenommen += 1;
|
||||||
|
bericht.bestandGesetzt += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vorhanden.owned) {
|
||||||
|
await prisma.libraryEntry.update({
|
||||||
|
where: { id: vorhanden.id },
|
||||||
|
data: { owned: true, ownedSource: "Jellyfin" },
|
||||||
|
});
|
||||||
|
bericht.bestandGesetzt += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sichtungen ---
|
||||||
|
if (verbindung.syncWatched) {
|
||||||
|
bericht.sichtungenAngelegt = await uebernehmeSichtungen(
|
||||||
|
userId,
|
||||||
|
connectionId,
|
||||||
|
gesehen,
|
||||||
|
eintragNachTitel,
|
||||||
|
titelNachTmdb,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Namen zuerst, damit die Auswahlliste durchsuchbar wirkt statt zufällig.
|
||||||
|
bericht.nichtZugeordnet = [...gesammelt.values()].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, "de-DE"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.mediaServerConnection.update({
|
||||||
|
where: { id: connectionId },
|
||||||
|
data: { lastSyncAt: new Date(), lastSyncReport: bericht as object },
|
||||||
|
});
|
||||||
|
|
||||||
|
return bericht;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blockweise lesen — eine große Mediathek passt nicht in eine Antwort. */
|
||||||
|
async function alleItems(baseUrl: string, token: string, serverUserId: string) {
|
||||||
|
const alle: JellyfinItem[] = [];
|
||||||
|
let startIndex = 0;
|
||||||
|
|
||||||
|
// Obergrenze als Reißleine: bei einem unerwarteten Serververhalten soll die
|
||||||
|
// Schleife enden, statt den Speicher zu füllen.
|
||||||
|
for (let runde = 0; runde < 200; runde += 1) {
|
||||||
|
const { items, total } = await fetchItems(baseUrl, token, serverUserId, {
|
||||||
|
startIndex,
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
alle.push(...items);
|
||||||
|
startIndex += items.length;
|
||||||
|
if (items.length === 0 || alle.length >= total) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return alle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tmdbIdOfEpisode(item: JellyfinItem, serien: Map<string, JellyfinItem>): number | null {
|
||||||
|
// Manche Bestände tragen die Serien-ID auch an der Episode; sonst über die Serie.
|
||||||
|
const direkt = tmdbIdOf(item);
|
||||||
|
if (direkt != null) return direkt;
|
||||||
|
const serie = item.SeriesId ? serien.get(item.SeriesId) : undefined;
|
||||||
|
return serie ? tmdbIdOf(serie) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merkt sich einen nicht zuzuordnenden Titel — zusammengefasst.
|
||||||
|
*
|
||||||
|
* Der Schlüssel ist die TMDB-Kennung, sonst der Name. Jellyfin führt oft
|
||||||
|
* mehrere Dateien zum selben Film; in der Auswahlliste soll er einmal stehen,
|
||||||
|
* mit dem Vermerk, wie viele Dateien darauf zeigen.
|
||||||
|
*/
|
||||||
|
function merkeUnzugeordnet(
|
||||||
|
bericht: SyncReport,
|
||||||
|
gesammelt: Map<string, UnassignedItem>,
|
||||||
|
item: JellyfinItem,
|
||||||
|
tmdbId: number | null,
|
||||||
|
grund: string,
|
||||||
|
) {
|
||||||
|
// Bei Episoden zählt die Serie: einzelne Folgen einer unbekannten Serie
|
||||||
|
// ergeben keine sinnvolle Auswahl, die Serie schon.
|
||||||
|
const istEpisode = item.Type === "Episode";
|
||||||
|
// Der Typ kommt aus dem Item, nicht aus dem Umkehrschluss „keine Episode,
|
||||||
|
// also Film". Jellyfin liefert bei dieser Abfrage zwar nur Movie und
|
||||||
|
// Episode, aber ein Server, der auch Series mitschickt, bekäme sonst
|
||||||
|
// „Film" ans Ohr geheftet.
|
||||||
|
const istSerie = istEpisode || item.Type === "Series";
|
||||||
|
const name = istEpisode ? (item.SeriesName ?? item.Name ?? "Ohne Titel") : (item.Name ?? "Ohne Titel");
|
||||||
|
const schluessel = tmdbId != null ? `tmdb:${tmdbId}` : `name:${name.toLocaleLowerCase("de-DE")}`;
|
||||||
|
|
||||||
|
const vorhanden = gesammelt.get(schluessel);
|
||||||
|
if (vorhanden) {
|
||||||
|
vorhanden.dateien += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesammelt.size >= BERICHT_GRENZE) {
|
||||||
|
bericht.weitereNichtZugeordnet += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gesammelt.set(schluessel, {
|
||||||
|
tmdbId,
|
||||||
|
typ: tmdbId == null ? null : istSerie ? "SERIES" : "MOVIE",
|
||||||
|
name,
|
||||||
|
grund,
|
||||||
|
dateien: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt Sichtungen an, die es noch nicht gibt.
|
||||||
|
*
|
||||||
|
* Zwei Arten von Dubletten müssen verhindert werden:
|
||||||
|
*
|
||||||
|
* - **Episoden** über `(userId, episodeId, run)` — dafür gibt es den
|
||||||
|
* eindeutigen Index, geprüft wird trotzdem vorher, damit kein Lauf an einem
|
||||||
|
* Konflikt scheitert.
|
||||||
|
* - **Filme** haben `episodeId = null`, und Postgres behandelt NULL in
|
||||||
|
* eindeutigen Indizes als jeweils verschieden. Der Index greift dort also
|
||||||
|
* nicht — es wird ausdrücklich nachgesehen.
|
||||||
|
*/
|
||||||
|
async function uebernehmeSichtungen(
|
||||||
|
userId: string,
|
||||||
|
connectionId: string,
|
||||||
|
gesehen: { titleId: string; seasonNumber: number | null; episodeNumber: number | null; wann: Date; externalId: string }[],
|
||||||
|
eintragNachTitel: Map<string, { id: string; titleId: string; currentRun: number; owned: boolean }>,
|
||||||
|
titelNachTmdb: Map<number, { id: string; type: string; runtime: number | null }>,
|
||||||
|
): Promise<number> {
|
||||||
|
if (gesehen.length === 0) return 0;
|
||||||
|
|
||||||
|
const laufzeitNachTitel = new Map(
|
||||||
|
[...titelNachTmdb.values()].map((titel) => [titel.id, titel.runtime ?? 0]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Episoden auflösen: Staffel- und Episodennummer → interne Episoden-ID.
|
||||||
|
const episodenSchluessel = gesehen.filter((eintrag) => eintrag.episodeNumber != null);
|
||||||
|
const episoden = await prisma.episode.findMany({
|
||||||
|
where: {
|
||||||
|
titleId: { in: [...new Set(episodenSchluessel.map((eintrag) => eintrag.titleId))] },
|
||||||
|
},
|
||||||
|
select: { id: true, titleId: true, seasonNumber: true, episodeNumber: true, runtime: true },
|
||||||
|
});
|
||||||
|
const episodeNachSchluessel = new Map(
|
||||||
|
episoden.map((episode) => [
|
||||||
|
`${episode.titleId}:${episode.seasonNumber}:${episode.episodeNumber}`,
|
||||||
|
episode,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let angelegt = 0;
|
||||||
|
|
||||||
|
for (const eintrag of gesehen) {
|
||||||
|
const bibliothek = eintragNachTitel.get(eintrag.titleId);
|
||||||
|
// Ohne Bibliothekseintrag keine Sichtung — sonst entstünden Sichtungen zu
|
||||||
|
// Titeln, die in Flimmer gar nicht geführt werden.
|
||||||
|
if (!bibliothek) continue;
|
||||||
|
|
||||||
|
const run = bibliothek.currentRun;
|
||||||
|
|
||||||
|
if (eintrag.episodeNumber != null) {
|
||||||
|
const episode = episodeNachSchluessel.get(
|
||||||
|
`${eintrag.titleId}:${eintrag.seasonNumber}:${eintrag.episodeNumber}`,
|
||||||
|
);
|
||||||
|
if (!episode) continue;
|
||||||
|
|
||||||
|
const schonDa = await prisma.watch.findFirst({
|
||||||
|
where: { userId, episodeId: episode.id, run },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (schonDa) continue;
|
||||||
|
|
||||||
|
await prisma.watch.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
entryId: bibliothek.id,
|
||||||
|
titleId: eintrag.titleId,
|
||||||
|
episodeId: episode.id,
|
||||||
|
run,
|
||||||
|
watchedAt: eintrag.wann,
|
||||||
|
minutes: episode.runtime ?? laufzeitNachTitel.get(eintrag.titleId) ?? 0,
|
||||||
|
source: "JELLYFIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
angelegt += 1;
|
||||||
|
} else {
|
||||||
|
const schonDa = await prisma.watch.findFirst({
|
||||||
|
where: { userId, entryId: bibliothek.id, episodeId: null, run },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (schonDa) continue;
|
||||||
|
|
||||||
|
await prisma.watch.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
entryId: bibliothek.id,
|
||||||
|
titleId: eintrag.titleId,
|
||||||
|
run,
|
||||||
|
watchedAt: eintrag.wann,
|
||||||
|
minutes: laufzeitNachTitel.get(eintrag.titleId) ?? 0,
|
||||||
|
source: "JELLYFIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
angelegt += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.mediaServerItem.upsert({
|
||||||
|
where: { connectionId_externalId: { connectionId, externalId: eintrag.externalId } },
|
||||||
|
create: { connectionId, externalId: eintrag.externalId, titleId: eintrag.titleId },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return angelegt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt ausgewählte Titel aus TMDB nach und nimmt sie in die Bibliothek auf.
|
||||||
|
*
|
||||||
|
* Bewusst getrennt vom Abgleich: der läuft nachts und soll nie ungefragt
|
||||||
|
* hunderte Titel importieren. Das hier passiert nur auf Klick, mit einer
|
||||||
|
* Auswahl, die jemand getroffen hat.
|
||||||
|
*
|
||||||
|
* Ein einzelner Fehlschlag beendet den Lauf nicht — TMDB kennt nicht jede
|
||||||
|
* Kennung, die in einer Mediathek steht, und ein unbekannter Film soll die
|
||||||
|
* anderen neunundneunzig nicht mitreißen.
|
||||||
|
*/
|
||||||
|
export async function importUnassigned(
|
||||||
|
userId: string,
|
||||||
|
connectionId: string,
|
||||||
|
auswahl: { tmdbId: number; typ: "SERIES" | "MOVIE" }[],
|
||||||
|
): Promise<{ nachgeladen: number; fehler: string[]; uebrig: number }> {
|
||||||
|
await requireConnection(userId, connectionId);
|
||||||
|
|
||||||
|
if (!env.tmdb.isConfigured) {
|
||||||
|
throw new MediaServerError(
|
||||||
|
"Ohne TMDB-Schlüssel lassen sich keine Titel nachladen. Trage TMDB_API_KEY in der .env ein.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obergrenze je Lauf: 100 Serien bedeuten mit Staffeln und Episoden schnell
|
||||||
|
// mehrere hundert TMDB-Abrufe. Der Rest kommt beim nächsten Mal.
|
||||||
|
const stapel = auswahl.slice(0, IMPORT_GRENZE);
|
||||||
|
const uebrig = Math.max(0, auswahl.length - stapel.length);
|
||||||
|
|
||||||
|
const fehler: string[] = [];
|
||||||
|
const geholt: number[] = [];
|
||||||
|
|
||||||
|
for (const eintrag of stapel) {
|
||||||
|
try {
|
||||||
|
const titel = await ensureTitle(eintrag.typ, eintrag.tmdbId, { region: env.tmdb.region });
|
||||||
|
|
||||||
|
// Aufnehmen und gleich als vorhanden markieren — er liegt ja auf dem
|
||||||
|
// Server. Ein bestehender Eintrag wird dabei nicht überschrieben.
|
||||||
|
const vorhanden = await prisma.libraryEntry.findUnique({
|
||||||
|
where: { userId_titleId: { userId, titleId: titel.id } },
|
||||||
|
select: { id: true, owned: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vorhanden) {
|
||||||
|
await prisma.libraryEntry.create({
|
||||||
|
data: { userId, titleId: titel.id, status: "WATCHLIST", owned: true, ownedSource: "Jellyfin" },
|
||||||
|
});
|
||||||
|
} else if (!vorhanden.owned) {
|
||||||
|
await prisma.libraryEntry.update({
|
||||||
|
where: { id: vorhanden.id },
|
||||||
|
data: { owned: true, ownedSource: "Jellyfin" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
geholt.push(eintrag.tmdbId);
|
||||||
|
} catch (error) {
|
||||||
|
fehler.push(
|
||||||
|
`TMDB ${eintrag.tmdbId}: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur was tatsächlich ankam, verschwindet aus der Auswahlliste. Ein
|
||||||
|
// Fehlschlag — TMDB kurz nicht erreichbar, Kennung unbekannt — soll den
|
||||||
|
// Eintrag stehen lassen, damit man es noch einmal versuchen kann, ohne
|
||||||
|
// vorher den ganzen Abgleich zu wiederholen.
|
||||||
|
await entferneAusBericht(connectionId, geholt);
|
||||||
|
|
||||||
|
return { nachgeladen: geholt.length, fehler, uebrig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streicht die geholten Titel aus dem gespeicherten Bericht.
|
||||||
|
*
|
||||||
|
* Ohne das stünden sie nach dem Import weiter in der Auswahlliste, und man
|
||||||
|
* würde sie beim nächsten Mal erneut anhaken.
|
||||||
|
*/
|
||||||
|
async function entferneAusBericht(connectionId: string, tmdbIds: number[]) {
|
||||||
|
if (tmdbIds.length === 0) return;
|
||||||
|
|
||||||
|
const verbindung = await prisma.mediaServerConnection.findUnique({
|
||||||
|
where: { id: connectionId },
|
||||||
|
select: { lastSyncReport: true },
|
||||||
|
});
|
||||||
|
const bericht = verbindung?.lastSyncReport as SyncReport | null;
|
||||||
|
if (!bericht?.nichtZugeordnet) return;
|
||||||
|
|
||||||
|
const geholt = new Set(tmdbIds);
|
||||||
|
const rest = bericht.nichtZugeordnet.filter(
|
||||||
|
(eintrag) => eintrag.tmdbId == null || !geholt.has(eintrag.tmdbId),
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.mediaServerConnection.update({
|
||||||
|
where: { id: connectionId },
|
||||||
|
data: {
|
||||||
|
lastSyncReport: {
|
||||||
|
...bericht,
|
||||||
|
nichtZugeordnet: rest,
|
||||||
|
nachgeladen: (bericht.nachgeladen ?? 0) + tmdbIds.length,
|
||||||
|
} as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle fälligen Verbindungen abgleichen — für den täglichen Lauf.
|
||||||
|
*
|
||||||
|
* Neue Titel werden dabei **nicht** automatisch aufgenommen: das ist eine
|
||||||
|
* Entscheidung, die jemand bewusst treffen soll, nicht etwas, das nachts
|
||||||
|
* ungefragt die Bibliothek füllt.
|
||||||
|
*/
|
||||||
|
export async function syncAllConnections(): Promise<{ verbindungen: number; berichte: SyncReport[] }> {
|
||||||
|
const verbindungen = await prisma.mediaServerConnection.findMany({
|
||||||
|
where: { enabled: true },
|
||||||
|
select: { id: true, userId: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const berichte: SyncReport[] = [];
|
||||||
|
for (const verbindung of verbindungen) {
|
||||||
|
try {
|
||||||
|
berichte.push(await syncConnection(verbindung.userId, verbindung.id));
|
||||||
|
} catch (error) {
|
||||||
|
const bericht = LEERER_BERICHT();
|
||||||
|
bericht.fehler.push(
|
||||||
|
`${verbindung.name}: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||||
|
);
|
||||||
|
berichte.push(bericht);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { verbindungen: verbindungen.length, berichte };
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import {
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
} from "@simplewebauthn/server";
|
||||||
|
import type {
|
||||||
|
AuthenticationResponseJSON,
|
||||||
|
AuthenticatorTransportFuture,
|
||||||
|
RegistrationResponseJSON,
|
||||||
|
} from "@simplewebauthn/server";
|
||||||
|
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldung per Passkey (WebAuthn).
|
||||||
|
*
|
||||||
|
* Ein Passkey ist der dritte Weg in denselben Trichter: `finishLogin()` prüft
|
||||||
|
* die Antwort und gibt die Kennung zurück, der Endpunkt legt damit über
|
||||||
|
* `createSession()` dieselbe Sitzung an wie bei Passwort und OIDC. Die
|
||||||
|
* Sitzungsschicht weiß nicht, wie jemand hereingekommen ist.
|
||||||
|
*
|
||||||
|
* Die Trennung ist Absicht: hier steht die WebAuthn-Prüfung, das Setzen von
|
||||||
|
* Cookie und Sitzungszeile gehört zur HTTP-Schicht.
|
||||||
|
*
|
||||||
|
* Zwei Dinge sind bei einer selbst gehosteten Instanz wichtiger als sonst:
|
||||||
|
*
|
||||||
|
* - Die **RP ID** kommt aus `APP_URL`, nicht aus dem `Host`-Header. Hinter
|
||||||
|
* einem Reverse Proxy hinge sie sonst am falschen Namen — und ein Passkey
|
||||||
|
* ist fest an diesen Namen gebunden.
|
||||||
|
* - Ein **Domainwechsel entwertet alle Passkeys.** Das steht in der README;
|
||||||
|
* hier lässt sich nichts dagegen tun, es ist Teil des Verfahrens.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class PasskeyError extends Error {}
|
||||||
|
|
||||||
|
const CHALLENGE_COOKIE = "flimmer_passkey_challenge";
|
||||||
|
/** Kurz genug, dass eine liegengebliebene Challenge nicht ewig gilt. */
|
||||||
|
const CHALLENGE_SECONDS = 300;
|
||||||
|
|
||||||
|
function rpId(): string {
|
||||||
|
return env.passkeys.rpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Ursprung, den der Browser in der Antwort mitschickt. */
|
||||||
|
function expectedOrigin(): string {
|
||||||
|
return new URL(env.appUrl).origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Challenge — signiertes Cookie statt Tabelle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Challenge muss den Weg vom ersten zum zweiten Schritt überstehen.
|
||||||
|
*
|
||||||
|
* Eine Tabelle bräuchte einen Aufräumjob für alles, was liegen bleibt, wenn
|
||||||
|
* jemand den Dialog abbricht. Ein signiertes, kurzlebiges Cookie kommt ohne
|
||||||
|
* beides aus: Fälschen geht nicht, und nach fünf Minuten ist es ohnehin weg.
|
||||||
|
*/
|
||||||
|
function sign(payload: string): string {
|
||||||
|
return createHmac("sha256", env.authSecret).update(payload).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeChallenge(challenge: string, scope: "register" | "login") {
|
||||||
|
const payload = `${scope}.${challenge}.${Date.now() + CHALLENGE_SECONDS * 1000}`;
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(CHALLENGE_COOKIE, `${payload}.${sign(payload)}`, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
path: "/",
|
||||||
|
maxAge: CHALLENGE_SECONDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function takeChallenge(scope: "register" | "login"): Promise<string> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const raw = cookieStore.get(CHALLENGE_COOKIE)?.value;
|
||||||
|
// Einmalgebrauch: gleich löschen, egal ob die Prüfung gelingt.
|
||||||
|
cookieStore.delete(CHALLENGE_COOKIE);
|
||||||
|
|
||||||
|
if (!raw) throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
|
||||||
|
|
||||||
|
const teile = raw.split(".");
|
||||||
|
if (teile.length !== 4) throw new PasskeyError("Die Anfrage ist ungültig.");
|
||||||
|
|
||||||
|
const [gespeicherterScope, challenge, ablauf, signatur] = teile as [string, string, string, string];
|
||||||
|
const payload = `${gespeicherterScope}.${challenge}.${ablauf}`;
|
||||||
|
|
||||||
|
const erwartet = Buffer.from(sign(payload));
|
||||||
|
const gegeben = Buffer.from(signatur);
|
||||||
|
if (erwartet.length !== gegeben.length || !timingSafeEqual(erwartet, gegeben)) {
|
||||||
|
throw new PasskeyError("Die Anfrage ist ungültig.");
|
||||||
|
}
|
||||||
|
if (gespeicherterScope !== scope) throw new PasskeyError("Die Anfrage passt nicht.");
|
||||||
|
if (Number(ablauf) < Date.now()) {
|
||||||
|
throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAvailable() {
|
||||||
|
if (!env.passkeys.enabled) throw new PasskeyError("Passkeys sind auf dieser Instanz abgeschaltet.");
|
||||||
|
if (!env.passkeys.isAvailable) {
|
||||||
|
throw new PasskeyError(
|
||||||
|
"Passkeys brauchen HTTPS (oder localhost). Diese Instanz läuft über eine unverschlüsselte Verbindung.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Anlegen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function startRegistration(userId: string) {
|
||||||
|
requireAvailable();
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, email: true, name: true },
|
||||||
|
});
|
||||||
|
const vorhandene = await prisma.passkey.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { credentialId: true, transports: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: env.appName,
|
||||||
|
rpID: rpId(),
|
||||||
|
userName: user.email,
|
||||||
|
userDisplayName: user.name,
|
||||||
|
// Die Kennung muss über Umbenennungen hinweg stabil bleiben — deshalb die
|
||||||
|
// interne ID und nicht die E-Mail-Adresse.
|
||||||
|
userID: new TextEncoder().encode(user.id),
|
||||||
|
attestationType: "none",
|
||||||
|
// Schon vorhandene Passkeys ausschließen, damit dasselbe Gerät nicht
|
||||||
|
// zweimal registriert wird.
|
||||||
|
excludeCredentials: vorhandene.map((eintrag) => ({
|
||||||
|
id: eintrag.credentialId,
|
||||||
|
transports: eintrag.transports as AuthenticatorTransportFuture[],
|
||||||
|
})),
|
||||||
|
authenticatorSelection: {
|
||||||
|
// Auffindbar, damit die Anmeldung ohne vorherige E-Mail-Eingabe geht.
|
||||||
|
residentKey: "required",
|
||||||
|
userVerification: "preferred",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await storeChallenge(options.challenge, "register");
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finishRegistration(
|
||||||
|
userId: string,
|
||||||
|
response: RegistrationResponseJSON,
|
||||||
|
name: string,
|
||||||
|
) {
|
||||||
|
requireAvailable();
|
||||||
|
const challenge = await takeChallenge("register");
|
||||||
|
|
||||||
|
const geprueft = await verifyRegistrationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: expectedOrigin(),
|
||||||
|
expectedRPID: rpId(),
|
||||||
|
requireUserVerification: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!geprueft.verified || !geprueft.registrationInfo) {
|
||||||
|
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credential, credentialDeviceType, credentialBackedUp } = geprueft.registrationInfo;
|
||||||
|
|
||||||
|
const schonVergeben = await prisma.passkey.findUnique({
|
||||||
|
where: { credentialId: credential.id },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (schonVergeben) {
|
||||||
|
throw new PasskeyError("Dieser Passkey ist bereits hinterlegt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.passkey.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
credentialId: credential.id,
|
||||||
|
publicKey: Buffer.from(credential.publicKey),
|
||||||
|
counter: BigInt(credential.counter),
|
||||||
|
transports: (credential.transports ?? []) as string[],
|
||||||
|
deviceType: credentialDeviceType,
|
||||||
|
backedUp: credentialBackedUp,
|
||||||
|
name: name.trim().slice(0, 60) || "Passkey",
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, deviceType: true, backedUp: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Anmelden
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function startLogin() {
|
||||||
|
requireAvailable();
|
||||||
|
|
||||||
|
// Ohne `allowCredentials`: der Browser sucht selbst einen passenden Passkey.
|
||||||
|
// Genau das erlaubt die Anmeldung ohne vorherige E-Mail-Eingabe — und
|
||||||
|
// verrät nebenbei nicht, welche Konten es auf dieser Instanz gibt.
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: rpId(),
|
||||||
|
userVerification: "preferred",
|
||||||
|
});
|
||||||
|
|
||||||
|
await storeChallenge(options.challenge, "login");
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finishLogin(response: AuthenticationResponseJSON) {
|
||||||
|
requireAvailable();
|
||||||
|
const challenge = await takeChallenge("login");
|
||||||
|
|
||||||
|
const passkey = await prisma.passkey.findUnique({
|
||||||
|
where: { credentialId: response.id },
|
||||||
|
include: { user: { select: { id: true, isActive: true } } },
|
||||||
|
});
|
||||||
|
if (!passkey) throw new PasskeyError("Dieser Passkey gehört zu keinem Konto hier.");
|
||||||
|
if (!passkey.user.isActive) throw new PasskeyError("Dieses Konto ist gesperrt.");
|
||||||
|
|
||||||
|
// Die Bibliothek prüft Signatur, Ursprung, RP ID **und** den Zähler. Sie
|
||||||
|
// wirft dabei gewöhnliche Error — hier übersetzt, damit eine erwartete
|
||||||
|
// Ablehnung nicht als unerwarteter Serverfehler im Log landet.
|
||||||
|
let geprueft;
|
||||||
|
try {
|
||||||
|
geprueft = await verifyAuthenticationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: expectedOrigin(),
|
||||||
|
expectedRPID: rpId(),
|
||||||
|
requireUserVerification: false,
|
||||||
|
credential: {
|
||||||
|
id: passkey.credentialId,
|
||||||
|
publicKey: new Uint8Array(passkey.publicKey),
|
||||||
|
counter: Number(passkey.counter),
|
||||||
|
transports: passkey.transports as AuthenticatorTransportFuture[],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const text = error instanceof Error ? error.message : "";
|
||||||
|
if (/counter/i.test(text)) {
|
||||||
|
throw new PasskeyError(
|
||||||
|
"Dieser Passkey wurde abgelehnt — der Zähler passt nicht. Melde dich anders an und lege ihn neu an.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!geprueft.verified) throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
|
||||||
|
|
||||||
|
// Zusätzliche Sicherung. Die Bibliothek prüft den Zähler nur, wenn beide
|
||||||
|
// Werte über null liegen; ein Authenticator, der von einem gezählten auf
|
||||||
|
// einen ungezählten Stand zurückfällt, käme dort durch.
|
||||||
|
const neuerZaehler = geprueft.authenticationInfo.newCounter;
|
||||||
|
const alterZaehler = Number(passkey.counter);
|
||||||
|
if (alterZaehler > 0 && neuerZaehler <= alterZaehler) {
|
||||||
|
throw new PasskeyError("Dieser Passkey wurde abgelehnt. Bitte melde dich anders an.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.passkey.update({
|
||||||
|
where: { id: passkey.id },
|
||||||
|
data: { counter: BigInt(neuerZaehler), lastUsedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { userId: passkey.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Verwalten
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function listPasskeys(userId: string) {
|
||||||
|
return prisma.passkey.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
deviceType: true,
|
||||||
|
backedUp: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastUsedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renamePasskey(userId: string, passkeyId: string, name: string) {
|
||||||
|
const treffer = await prisma.passkey.updateMany({
|
||||||
|
where: { id: passkeyId, userId },
|
||||||
|
data: { name: name.trim().slice(0, 60) || "Passkey" },
|
||||||
|
});
|
||||||
|
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt einen Passkey — aber nie den letzten Anmeldeweg.
|
||||||
|
*
|
||||||
|
* Wer ein Konto ohne Passwort hat (etwa über OIDC angelegt) und seinen einzigen
|
||||||
|
* Passkey löscht, käme sonst nicht mehr herein.
|
||||||
|
*/
|
||||||
|
export async function deletePasskey(userId: string, passkeyId: string) {
|
||||||
|
const [user, anzahl] = await Promise.all([
|
||||||
|
prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { passwordHash: true, _count: { select: { oidcAccounts: true } } },
|
||||||
|
}),
|
||||||
|
prisma.passkey.count({ where: { userId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const andereWege = Boolean(user.passwordHash) || user._count.oidcAccounts > 0;
|
||||||
|
if (!andereWege && anzahl <= 1) {
|
||||||
|
throw new PasskeyError(
|
||||||
|
"Das ist dein letzter Anmeldeweg. Lege zuerst ein Passwort oder einen weiteren Passkey an.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const treffer = await prisma.passkey.deleteMany({ where: { id: passkeyId, userId } });
|
||||||
|
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschlüsseln von Geheimnissen, die in der Datenbank liegen müssen.
|
||||||
|
*
|
||||||
|
* Anders als Passwörter und Sitzungstoken lassen sich Zugangstoken fremder
|
||||||
|
* Dienste nicht hashen — sie werden im Klartext gebraucht, um sich beim
|
||||||
|
* Medienserver anzumelden. Sie dürfen aber auch nicht im Klartext in der
|
||||||
|
* Datenbank stehen: ein Datenbank-Dump landet schneller irgendwo, als einem
|
||||||
|
* lieb ist.
|
||||||
|
*
|
||||||
|
* AES-256-GCM mit einem aus `AUTH_SECRET` abgeleiteten Schlüssel. GCM prüft
|
||||||
|
* beim Entschlüsseln gleich mit, ob der Inhalt unverändert ist.
|
||||||
|
*
|
||||||
|
* Zu wissen: **Wer `AUTH_SECRET` austauscht, macht die gespeicherten Token
|
||||||
|
* unlesbar.** Sie müssen dann neu hinterlegt werden — das ist derselbe
|
||||||
|
* Effekt, den ein neues Secret ohnehin auf alle Sitzungen hat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALGORITHM = "aes-256-gcm";
|
||||||
|
|
||||||
|
function key(): Buffer {
|
||||||
|
// AUTH_SECRET ist eine beliebig lange Zeichenkette; AES braucht genau 32 Byte.
|
||||||
|
return createHash("sha256").update(`medienserver:${env.authSecret}`).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptSecret(plain: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, key(), iv);
|
||||||
|
const verschluesselt = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return [iv.toString("base64url"), tag.toString("base64url"), verschluesselt.toString("base64url")].join(
|
||||||
|
".",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptSecret(stored: string): string {
|
||||||
|
const teile = stored.split(".");
|
||||||
|
if (teile.length !== 3) throw new Error("Das gespeicherte Geheimnis hat ein unbekanntes Format.");
|
||||||
|
|
||||||
|
const [iv, tag, nutzlast] = teile as [string, string, string];
|
||||||
|
const decipher = createDecipheriv(ALGORITHM, key(), Buffer.from(iv, "base64url"));
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, "base64url"));
|
||||||
|
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(nutzlast, "base64url")),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString("utf8");
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { prisma } from "./db";
|
|||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
|
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
|
||||||
import { notifyUser } from "./notify";
|
import { notifyUser } from "./notify";
|
||||||
|
import { syncAllConnections } from "./mediaserver";
|
||||||
|
import { evaluateAllAchievements } from "./achievements";
|
||||||
import { episodeCode } from "./labels";
|
import { episodeCode } from "./labels";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +28,9 @@ export type SyncStats = {
|
|||||||
notificationsSent: number;
|
notificationsSent: number;
|
||||||
entriesPurged: number;
|
entriesPurged: number;
|
||||||
sessionsPurged: number;
|
sessionsPurged: number;
|
||||||
|
mediaServersSynced: number;
|
||||||
|
mediaServerWatches: number;
|
||||||
|
achievementsUnlocked: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -232,6 +237,9 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
|
|||||||
notificationsSent: 0,
|
notificationsSent: 0,
|
||||||
entriesPurged: 0,
|
entriesPurged: 0,
|
||||||
sessionsPurged: 0,
|
sessionsPurged: 0,
|
||||||
|
mediaServersSynced: 0,
|
||||||
|
mediaServerWatches: 0,
|
||||||
|
achievementsUnlocked: 0,
|
||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,6 +291,33 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Medienserver abgleichen. Neue Titel werden dabei bewusst nicht
|
||||||
|
// aufgenommen — das soll niemand nachts ungefragt in der Bibliothek finden.
|
||||||
|
try {
|
||||||
|
const medien = await syncAllConnections();
|
||||||
|
stats.mediaServersSynced = medien.verbindungen;
|
||||||
|
for (const bericht of medien.berichte) {
|
||||||
|
stats.mediaServerWatches += bericht.sichtungenAngelegt;
|
||||||
|
stats.errors.push(...bericht.fehler);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
stats.errors.push(
|
||||||
|
`Medienserver: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errungenschaften: einmal täglich vollständig. Die Regeln, die nicht an
|
||||||
|
// einer Sichtung hängen (Zeitspannen, abgeschlossene Reihen), finden hier
|
||||||
|
// ihren Weg — inline waeren sie zu teuer.
|
||||||
|
try {
|
||||||
|
const abzeichen = await evaluateAllAchievements();
|
||||||
|
stats.achievementsUnlocked = abzeichen.vergeben;
|
||||||
|
} catch (error) {
|
||||||
|
stats.errors.push(
|
||||||
|
`Errungenschaften: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const purged = await purgeExpired();
|
const purged = await purgeExpired();
|
||||||
stats.entriesPurged = purged.entries;
|
stats.entriesPurged = purged.entries;
|
||||||
stats.sessionsPurged = purged.sessions;
|
stats.sessionsPurged = purged.sessions;
|
||||||
|
|||||||
@@ -244,13 +244,54 @@ export async function renameOrder(userId: string, orderId: string, name: string)
|
|||||||
export async function deleteOrder(userId: string, orderId: string) {
|
export async function deleteOrder(userId: string, orderId: string) {
|
||||||
const order = await prisma.universeOrder.findUniqueOrThrow({
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
select: { universeId: true, kind: true },
|
select: { universeId: true, kind: true, isDefault: true },
|
||||||
});
|
});
|
||||||
await requireWrite(userId, order.universeId);
|
await requireWrite(userId, order.universeId);
|
||||||
if (order.kind === "RELEASE") {
|
if (order.kind === "RELEASE") {
|
||||||
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
|
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
|
||||||
}
|
}
|
||||||
await prisma.universeOrder.delete({ where: { id: orderId } });
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.universeOrder.delete({ where: { id: orderId } });
|
||||||
|
|
||||||
|
// War die gelöschte der Standard, stünde das Universum ohne einen da und
|
||||||
|
// die Auswahl fiele auf „die erste" — Zufall statt Absicht. Die
|
||||||
|
// Erscheinungsreihenfolge gehört immer dazu, sie übernimmt.
|
||||||
|
if (order.isDefault) {
|
||||||
|
await tx.universeOrder.updateMany({
|
||||||
|
where: { universeId: order.universeId, kind: "RELEASE" },
|
||||||
|
data: { isDefault: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt fest, welche Reihenfolge beim Öffnen des Universums erscheint.
|
||||||
|
*
|
||||||
|
* Der Standard gehört dem Universum, nicht der einzelnen Person: wer
|
||||||
|
* Schreibrecht hat, setzt ihn für alle, die das Universum sehen — so wie die
|
||||||
|
* Reihenfolgen selbst auch geteilt sind.
|
||||||
|
*
|
||||||
|
* Zurücksetzen und Setzen laufen gemeinsam in einer Transaktion. Sonst stünden
|
||||||
|
* dazwischen zwei Standards nebeneinander, und die Auswahl in `getUniverse()`
|
||||||
|
* griffe sich nach Sortierzufall einen davon.
|
||||||
|
*/
|
||||||
|
export async function setDefaultOrder(userId: string, orderId: string) {
|
||||||
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { universeId: true, isDefault: true },
|
||||||
|
});
|
||||||
|
await requireWrite(userId, order.universeId);
|
||||||
|
if (order.isDefault) return;
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.universeOrder.updateMany({
|
||||||
|
where: { universeId: order.universeId, isDefault: true },
|
||||||
|
data: { isDefault: false },
|
||||||
|
}),
|
||||||
|
prisma.universeOrder.update({ where: { id: orderId }, data: { isDefault: true } }),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
|
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import "./setup";
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { after, before, beforeEach } from "node:test";
|
||||||
|
|
||||||
|
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./fixtures/tmdb";
|
||||||
|
import { prisma } from "../src/lib/db";
|
||||||
|
import { importMovie, importSeries } from "../src/lib/catalog";
|
||||||
|
import { addToLibrary, markMovieWatched, markEpisodesWatched } from "../src/lib/library";
|
||||||
|
import {
|
||||||
|
achievementName,
|
||||||
|
evaluateAchievements,
|
||||||
|
listAchievements,
|
||||||
|
listUnlockedAchievements,
|
||||||
|
} from "../src/lib/achievements";
|
||||||
|
import { kuratierteReihe } from "../src/lib/achievement-names";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Errungenschaften: Vergabe, Entdopplung, die Stummschaltung beim
|
||||||
|
* ersten Lauf und die Absicherung gegen falsch abgeschriebene Reihen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const tmdb = installTmdbStub();
|
||||||
|
const TEST_EMAIL = "test-achievements@flimmer.invalid";
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
let seriesTitleId: string;
|
||||||
|
let movieTitleId: string;
|
||||||
|
|
||||||
|
async function aufraeumen() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await aufraeumen();
|
||||||
|
userId = (
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { email: TEST_EMAIL, name: "Sammlerin" },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
|
||||||
|
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
|
||||||
|
movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
|
||||||
|
await importMovie(MOVIE_2_ID, "DE");
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.achievement.deleteMany({ where: { userId } });
|
||||||
|
await prisma.watch.deleteMany({ where: { userId } });
|
||||||
|
await prisma.libraryEntry.deleteMany({ where: { userId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await aufraeumen();
|
||||||
|
tmdb.restore();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("der erste Lauf eines Kontos ist stumm", async () => {
|
||||||
|
// Bestandskonten bringen Jahre an Daten mit. Zwei Dutzend Meldungen auf
|
||||||
|
// einmal waeren Laerm, kein Erfolgserlebnis.
|
||||||
|
await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
|
||||||
|
const erster = await evaluateAchievements(userId);
|
||||||
|
assert.equal(erster.stumm, true, "der erste Lauf meldet nichts");
|
||||||
|
assert.deepEqual(erster.neu, [], "auch wenn er Abzeichen vergibt");
|
||||||
|
|
||||||
|
// Vergeben wurde trotzdem.
|
||||||
|
const vergeben = await prisma.achievement.count({ where: { userId } });
|
||||||
|
assert.ok(vergeben > 0, "Erstkontakt ist erreicht");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ab dem zweiten Lauf wird gemeldet", async () => {
|
||||||
|
await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
await evaluateAchievements(userId); // stummer Erstlauf
|
||||||
|
|
||||||
|
const eintrag = await prisma.libraryEntry.findFirstOrThrow({
|
||||||
|
where: { userId, titleId: movieTitleId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await markMovieWatched(userId, eintrag.id);
|
||||||
|
await prisma.libraryEntry.update({ where: { id: eintrag.id }, data: { currentRun: 2 } });
|
||||||
|
|
||||||
|
const zweiter = await evaluateAchievements(userId);
|
||||||
|
assert.equal(zweiter.stumm, false);
|
||||||
|
assert.ok(
|
||||||
|
zweiter.neu.some((abzeichen) => abzeichen.key === "wiederholungstaeter"),
|
||||||
|
"der dritte Durchlauf wird gemeldet",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ein zweiter Lauf vergibt nichts doppelt", async () => {
|
||||||
|
await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
await evaluateAchievements(userId);
|
||||||
|
const nachErstem = await prisma.achievement.count({ where: { userId } });
|
||||||
|
|
||||||
|
const zweiter = await evaluateAchievements(userId);
|
||||||
|
assert.deepEqual(zweiter.neu, []);
|
||||||
|
assert.equal(await prisma.achievement.count({ where: { userId } }), nachErstem);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("einmal erreicht bleibt erreicht — auch wenn die Bedingung wegfällt", async () => {
|
||||||
|
const eintrag = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
await evaluateAchievements(userId);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
(await prisma.achievement.findFirst({ where: { userId, key: "erstkontakt" } })) != null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Titel wieder entfernen: die Bedingung ist nicht mehr erfüllt.
|
||||||
|
await prisma.libraryEntry.delete({ where: { id: eintrag.id } });
|
||||||
|
await evaluateAchievements(userId);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
(await prisma.achievement.findFirst({ where: { userId, key: "erstkontakt" } })) != null,
|
||||||
|
"das Abzeichen bleibt stehen",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Durchgemacht rechnet in der Zeitzone der Instanz", async () => {
|
||||||
|
const eintrag = await addToLibrary(userId, seriesTitleId, "WATCHING");
|
||||||
|
const episoden = await prisma.episode.findMany({
|
||||||
|
where: { titleId: seriesTitleId },
|
||||||
|
take: 1,
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await markEpisodesWatched(userId, eintrag.id, [episoden[0]!.id]);
|
||||||
|
|
||||||
|
// 03:30 Uhr in Europe/Berlin ist im Sommer 01:30 UTC. Wer in UTC rechnet,
|
||||||
|
// verpasst das Abzeichen — genau der Fehler, der schon einmal das
|
||||||
|
// Monatsdiagramm leergefegt hat.
|
||||||
|
await prisma.watch.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { watchedAt: new Date("2026-07-15T01:30:00.000Z") },
|
||||||
|
});
|
||||||
|
|
||||||
|
await evaluateAchievements(userId);
|
||||||
|
const nachts = await prisma.achievement.findFirst({ where: { userId, key: "durchgemacht" } });
|
||||||
|
assert.ok(nachts, "03:30 Ortszeit zählt als Nacht");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tagsueber gesehen loest Durchgemacht nicht aus", async () => {
|
||||||
|
const eintrag = await addToLibrary(userId, seriesTitleId, "WATCHING");
|
||||||
|
const episoden = await prisma.episode.findMany({
|
||||||
|
where: { titleId: seriesTitleId },
|
||||||
|
take: 1,
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await markEpisodesWatched(userId, eintrag.id, [episoden[0]!.id]);
|
||||||
|
await prisma.watch.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { watchedAt: new Date("2026-07-15T12:00:00.000Z") },
|
||||||
|
});
|
||||||
|
|
||||||
|
await evaluateAchievements(userId);
|
||||||
|
assert.equal(await prisma.achievement.count({ where: { userId, key: "durchgemacht" } }), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("die Übersicht zeigt Erreichtes und Offenes", async () => {
|
||||||
|
await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
await evaluateAchievements(userId);
|
||||||
|
|
||||||
|
const alle = await listAchievements(userId);
|
||||||
|
assert.ok(alle.length > 15, "der Katalog ist vollständig sichtbar");
|
||||||
|
|
||||||
|
const erreicht = alle.filter((abzeichen) => abzeichen.unlockedAt !== null);
|
||||||
|
const offen = alle.filter((abzeichen) => abzeichen.unlockedAt === null);
|
||||||
|
assert.ok(erreicht.length >= 1);
|
||||||
|
assert.ok(offen.length >= 1, "auch das noch nicht Erreichte steht da");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
await listUnlockedAchievements(userId),
|
||||||
|
erreicht,
|
||||||
|
"das öffentliche Profil zeigt nur Erreichtes",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Jedes Abzeichen trägt Name und Beschreibung — Farbe allein reicht nicht.
|
||||||
|
assert.ok(alle.every((abzeichen) => abzeichen.name && abzeichen.beschreibung));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ein kuratierter Name gilt nur, wenn er zur Reihe passt", async () => {
|
||||||
|
// Richtige Kennung mit passendem Namen: greift.
|
||||||
|
assert.equal(kuratierteReihe(556, "Spider-Man Filmreihe")?.name, "Spider Bro");
|
||||||
|
|
||||||
|
// Dieselbe Kennung, aber TMDB führt etwas ganz anderes darunter — dann darf
|
||||||
|
// der Eintrag nicht greifen. Genau so bleibt eine falsch abgeschriebene
|
||||||
|
// Kennung folgenlos.
|
||||||
|
assert.equal(kuratierteReihe(556, "Ocean's Filmreihe"), undefined);
|
||||||
|
|
||||||
|
// Unbekannte Kennung: nichts.
|
||||||
|
assert.equal(kuratierteReihe(999999, "Irgendwas"), undefined);
|
||||||
|
|
||||||
|
// Ohne Namen lässt sich nichts prüfen — im Zweifel generisch.
|
||||||
|
assert.equal(kuratierteReihe(556, null), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Anzeigenamen entstehen aus Schlüssel und eingefrorenem Namen", () => {
|
||||||
|
assert.equal(achievementName("marathon", null), "Marathon");
|
||||||
|
assert.equal(achievementName("reihe:556", "Spider-Man Filmreihe"), "Spider Bro");
|
||||||
|
assert.equal(
|
||||||
|
achievementName("reihe:12345", "Nordlicht Filmreihe"),
|
||||||
|
"Reihe komplett — Nordlicht Filmreihe",
|
||||||
|
"ohne kuratierten Eintrag der generische Name",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
achievementName("universum:abc", "Sternenpfad"),
|
||||||
|
"Universum vollendet — Sternenpfad",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ein abgeschlossenes Universum wird zum Abzeichen", async () => {
|
||||||
|
const eintrag = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
await markMovieWatched(userId, eintrag.id);
|
||||||
|
|
||||||
|
const universum = await prisma.universe.create({
|
||||||
|
data: {
|
||||||
|
ownerId: userId,
|
||||||
|
name: "Testuniversum",
|
||||||
|
slug: `test-achievements-${Date.now()}`,
|
||||||
|
items: { create: [{ titleId: movieTitleId }] },
|
||||||
|
orders: { create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }] },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await evaluateAchievements(userId);
|
||||||
|
|
||||||
|
const abzeichen = await prisma.achievement.findFirst({
|
||||||
|
where: { userId, key: `universum:${universum.id}` },
|
||||||
|
select: { label: true },
|
||||||
|
});
|
||||||
|
assert.ok(abzeichen, "das vollendete Universum zählt");
|
||||||
|
assert.equal(abzeichen.label, "Testuniversum", "der Name wird eingefroren");
|
||||||
|
|
||||||
|
// Umbenennen ändert das Abzeichen nicht mehr.
|
||||||
|
await prisma.universe.update({
|
||||||
|
where: { id: universum.id },
|
||||||
|
data: { name: "Anders benannt" },
|
||||||
|
});
|
||||||
|
await evaluateAchievements(userId);
|
||||||
|
const danach = await prisma.achievement.findFirstOrThrow({
|
||||||
|
where: { userId, key: `universum:${universum.id}` },
|
||||||
|
select: { label: true },
|
||||||
|
});
|
||||||
|
assert.equal(danach.label, "Testuniversum");
|
||||||
|
} finally {
|
||||||
|
await prisma.universe.delete({ where: { id: universum.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./tmdb";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachgebildete Jellyfin-Antworten.
|
||||||
|
*
|
||||||
|
* Damit lässt sich der ganze Abgleich prüfen, ohne einen Jellyfin-Server zu
|
||||||
|
* brauchen. Der Aufbau entspricht dem, was Jellyfin tatsächlich liefert —
|
||||||
|
* gekürzt auf die Felder, die Flimmer liest.
|
||||||
|
*
|
||||||
|
* Absichtlich enthalten: ein Item **ohne** TMDB-Kennung und eines mit einer
|
||||||
|
* Kennung, die nicht im Katalog steht. Beides muss im Bericht landen, statt
|
||||||
|
* den Lauf zu stören.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const JELLYFIN_URL = "http://jellyfin.test:8096";
|
||||||
|
export const JELLYFIN_USER_ID = "jf-user-0001";
|
||||||
|
export const JELLYFIN_TOKEN = "jf-token-abcdef";
|
||||||
|
|
||||||
|
export const SERIES_ITEM_ID = "jf-series-1";
|
||||||
|
|
||||||
|
export const items = {
|
||||||
|
/** Film, gesehen — mit Termin. */
|
||||||
|
filmGesehen: {
|
||||||
|
Id: "jf-movie-1",
|
||||||
|
Name: "Die Stille zwischen den Bildern",
|
||||||
|
Type: "Movie",
|
||||||
|
ProviderIds: { Tmdb: String(MOVIE_ID) },
|
||||||
|
ProductionYear: 2024,
|
||||||
|
UserData: { Played: true, PlayCount: 3, LastPlayedDate: "2026-07-14T20:15:00.0000000Z" },
|
||||||
|
},
|
||||||
|
/** Film, vorhanden aber nicht gesehen. */
|
||||||
|
filmUngesehen: {
|
||||||
|
Id: "jf-movie-2",
|
||||||
|
Name: "Die Stille zwischen den Bildern II",
|
||||||
|
Type: "Movie",
|
||||||
|
ProviderIds: { Tmdb: String(MOVIE_2_ID) },
|
||||||
|
ProductionYear: 2026,
|
||||||
|
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
|
||||||
|
},
|
||||||
|
/** Episode, gesehen — die TMDB-Kennung hängt an der Serie darüber. */
|
||||||
|
episodeGesehen: {
|
||||||
|
Id: "jf-episode-1",
|
||||||
|
Name: "Erste Nacht",
|
||||||
|
Type: "Episode",
|
||||||
|
SeriesId: SERIES_ITEM_ID,
|
||||||
|
SeriesName: "Nordlicht",
|
||||||
|
ParentIndexNumber: 1,
|
||||||
|
IndexNumber: 1,
|
||||||
|
ProviderIds: {},
|
||||||
|
UserData: { Played: true, PlayCount: 1, LastPlayedDate: "2026-08-01T21:00:00.0000000Z" },
|
||||||
|
},
|
||||||
|
/** Episode, ungesehen. */
|
||||||
|
episodeUngesehen: {
|
||||||
|
Id: "jf-episode-2",
|
||||||
|
Name: "Zweite Nacht",
|
||||||
|
Type: "Episode",
|
||||||
|
SeriesId: SERIES_ITEM_ID,
|
||||||
|
SeriesName: "Nordlicht",
|
||||||
|
ParentIndexNumber: 1,
|
||||||
|
IndexNumber: 2,
|
||||||
|
ProviderIds: {},
|
||||||
|
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
|
||||||
|
},
|
||||||
|
/** Ohne TMDB-Kennung — landet im Bericht. */
|
||||||
|
ohneKennung: {
|
||||||
|
Id: "jf-movie-3",
|
||||||
|
Name: "Urlaubsvideo 2019",
|
||||||
|
Type: "Movie",
|
||||||
|
ProviderIds: {},
|
||||||
|
UserData: { Played: true, PlayCount: 1, LastPlayedDate: "2026-01-01T12:00:00.0000000Z" },
|
||||||
|
},
|
||||||
|
/** Kennung vorhanden, aber nicht im Katalog — landet ebenfalls im Bericht. */
|
||||||
|
fremd: {
|
||||||
|
Id: "jf-movie-4",
|
||||||
|
Name: "Ein fremder Film",
|
||||||
|
Type: "Movie",
|
||||||
|
ProviderIds: { Tmdb: "424242" },
|
||||||
|
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Zweite Datei zu demselben fremden Film — so führt Jellyfin Extended
|
||||||
|
* Editions. In der Auswahlliste muss der Titel trotzdem einmal stehen.
|
||||||
|
*/
|
||||||
|
fremdZweiteFassung: {
|
||||||
|
Id: "jf-movie-5",
|
||||||
|
Name: "Ein fremder Film (Extended)",
|
||||||
|
Type: "Movie",
|
||||||
|
ProviderIds: { Tmdb: "424242" },
|
||||||
|
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const seriesItem = {
|
||||||
|
Id: SERIES_ITEM_ID,
|
||||||
|
Name: "Nordlicht",
|
||||||
|
Type: "Series",
|
||||||
|
ProviderIds: { Tmdb: String(SERIES_ID) },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALLE_ITEMS = Object.values(items);
|
||||||
|
|
||||||
|
type StubOptions = {
|
||||||
|
/** Ohne UserData — so verhält sich Jellyfin bei Anmeldung per API-Key. */
|
||||||
|
ohneUserData?: boolean;
|
||||||
|
/** Anmeldung ablehnen. */
|
||||||
|
abweisen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hängt sich in `fetch` und beantwortet die Jellyfin-Endpunkte.
|
||||||
|
*
|
||||||
|
* Gibt die aufgerufenen Pfade zurück — damit lässt sich prüfen, dass ein
|
||||||
|
* zweiter Lauf nicht mehr Arbeit macht als nötig.
|
||||||
|
*/
|
||||||
|
export function installJellyfinStub(options: StubOptions = {}) {
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = new URL(typeof input === "string" ? input : input.toString());
|
||||||
|
calls.push(url.pathname);
|
||||||
|
|
||||||
|
const json = (body: unknown) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (url.pathname === "/Users/AuthenticateByName") {
|
||||||
|
if (options.abweisen) return new Response("", { status: 401 });
|
||||||
|
return json({
|
||||||
|
AccessToken: JELLYFIN_TOKEN,
|
||||||
|
User: { Id: JELLYFIN_USER_ID, Name: "florian" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/System/Info") {
|
||||||
|
return json({ ServerName: "Heimserver", Version: "10.10.3" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/Items") {
|
||||||
|
const ids = url.searchParams.get("Ids");
|
||||||
|
if (ids) {
|
||||||
|
// Abfrage der Serien-Kopfdaten.
|
||||||
|
const gesucht = new Set(ids.split(","));
|
||||||
|
return json({ Items: gesucht.has(SERIES_ITEM_ID) ? [seriesItem] : [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = Number(url.searchParams.get("StartIndex") ?? "0");
|
||||||
|
const limit = Number(url.searchParams.get("Limit") ?? "500");
|
||||||
|
const alle = ALLE_ITEMS.map((item) =>
|
||||||
|
options.ohneUserData ? { ...item, UserData: undefined } : item,
|
||||||
|
);
|
||||||
|
return json({
|
||||||
|
Items: alle.slice(startIndex, startIndex + limit),
|
||||||
|
TotalRecordCount: alle.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ error: `Kein Fixture für ${url.pathname}` }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
restore() {
|
||||||
|
globalThis.fetch = original;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
startRewatch,
|
startRewatch,
|
||||||
undoWatches,
|
undoWatches,
|
||||||
unmarkEpisodes,
|
unmarkEpisodes,
|
||||||
|
unmarkMovie,
|
||||||
updateEntryDetails,
|
updateEntryDetails,
|
||||||
watchNextEpisode,
|
watchNextEpisode,
|
||||||
} from "../src/lib/library";
|
} from "../src/lib/library";
|
||||||
@@ -388,3 +389,56 @@ test("Sammlung: Filter und Zähler in der Bibliothek", async () => {
|
|||||||
|
|
||||||
await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } });
|
await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Filme: die Sichtung laesst sich wieder zuruecknehmen", async () => {
|
||||||
|
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
|
||||||
|
await markMovieWatched(userId, entry.id);
|
||||||
|
assert.equal((await getProgress(userId, entry.id)).percent, 100);
|
||||||
|
|
||||||
|
await unmarkMovie(userId, entry.id);
|
||||||
|
|
||||||
|
const zurueck = await getProgress(userId, entry.id);
|
||||||
|
assert.equal(zurueck.percent, 0, "der Fortschritt steht wieder auf null");
|
||||||
|
assert.equal(zurueck.minutesWatched, 0);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.watch.count({ where: { userId, entryId: entry.id } }),
|
||||||
|
0,
|
||||||
|
"die Sichtung ist weg",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Und danach laesst es sich erneut abhaken — kein Einbahnzustand.
|
||||||
|
const erneut = await markMovieWatched(userId, entry.id);
|
||||||
|
assert.equal(erneut.watchIds.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Filme: das Zuruecknehmen betrifft nur den laufenden Durchlauf", async () => {
|
||||||
|
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
|
||||||
|
await markMovieWatched(userId, entry.id);
|
||||||
|
await startRewatch(userId, entry.id);
|
||||||
|
await markMovieWatched(userId, entry.id);
|
||||||
|
assert.equal(await prisma.watch.count({ where: { userId, entryId: entry.id } }), 2);
|
||||||
|
|
||||||
|
await unmarkMovie(userId, entry.id);
|
||||||
|
|
||||||
|
const rest = await prisma.watch.findMany({
|
||||||
|
where: { userId, entryId: entry.id },
|
||||||
|
select: { run: true },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
rest.map((watch) => watch.run),
|
||||||
|
[0],
|
||||||
|
"die Sichtung des ersten Durchlaufs bleibt fuer die Statistik stehen",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await getProgress(userId, entry.id)).percent,
|
||||||
|
0,
|
||||||
|
"der laufende Durchlauf steht wieder auf null",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unmarkMovie weist Serien ab", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId, "WATCHING");
|
||||||
|
await assert.rejects(() => unmarkMovie(userId, entry.id), /keine Filmsichtung/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
import "./setup";
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { after, before, beforeEach } from "node:test";
|
||||||
|
|
||||||
|
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./fixtures/tmdb";
|
||||||
|
import { installJellyfinStub, JELLYFIN_URL, JELLYFIN_TOKEN } from "./fixtures/jellyfin";
|
||||||
|
import { prisma } from "../src/lib/db";
|
||||||
|
import { importMovie, importSeries } from "../src/lib/catalog";
|
||||||
|
import { addToLibrary, markMovieWatched } from "../src/lib/library";
|
||||||
|
import { decryptSecret, encryptSecret } from "../src/lib/secrets";
|
||||||
|
import {
|
||||||
|
connectJellyfin,
|
||||||
|
deleteConnection,
|
||||||
|
importUnassigned,
|
||||||
|
syncConnection,
|
||||||
|
testConnection,
|
||||||
|
} from "../src/lib/mediaserver";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft den Abgleich mit einem Medienserver — gegen nachgebildete Antworten,
|
||||||
|
* ohne echten Jellyfin-Server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const tmdb = installTmdbStub();
|
||||||
|
const TEST_EMAIL = "test-mediaserver@flimmer.invalid";
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
let movieTitleId: string;
|
||||||
|
let movie2TitleId: string;
|
||||||
|
|
||||||
|
async function aufraeumen() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await aufraeumen();
|
||||||
|
userId = (
|
||||||
|
await prisma.user.create({ data: { email: TEST_EMAIL, name: "Medienserver" }, select: { id: true } })
|
||||||
|
).id;
|
||||||
|
|
||||||
|
await importSeries(SERIES_ID, { region: "DE" });
|
||||||
|
movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
|
||||||
|
movie2TitleId = (await importMovie(MOVIE_2_ID, "DE")).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.mediaServerConnection.deleteMany({ where: { userId } });
|
||||||
|
await prisma.watch.deleteMany({ where: { userId } });
|
||||||
|
await prisma.libraryEntry.deleteMany({ where: { userId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await aufraeumen();
|
||||||
|
tmdb.restore();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function verbinden() {
|
||||||
|
const { verbindung } = await connectJellyfin(userId, {
|
||||||
|
baseUrl: JELLYFIN_URL,
|
||||||
|
username: "florian",
|
||||||
|
password: "geheim",
|
||||||
|
});
|
||||||
|
return verbindung;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("Geheimnisse gehen verschlüsselt hinein und im Klartext heraus", () => {
|
||||||
|
const klartext = "jf-token-abcdef";
|
||||||
|
const abgelegt = encryptSecret(klartext);
|
||||||
|
|
||||||
|
assert.notEqual(abgelegt, klartext, "im Klartext hat der Token nichts in der Datenbank verloren");
|
||||||
|
assert.ok(!abgelegt.includes(klartext));
|
||||||
|
assert.equal(decryptSecret(abgelegt), klartext);
|
||||||
|
|
||||||
|
// Zweimal verschlüsseln ergibt Verschiedenes — der Zufallswert wandert mit.
|
||||||
|
assert.notEqual(encryptSecret(klartext), abgelegt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("eine veränderte Ablage lässt sich nicht entschlüsseln", () => {
|
||||||
|
const abgelegt = encryptSecret("geheim");
|
||||||
|
const teile = abgelegt.split(".");
|
||||||
|
const verbogen = `${teile[0]}.${teile[1]}.${"A".repeat(teile[2]!.length)}`;
|
||||||
|
|
||||||
|
assert.throws(() => decryptSecret(verbogen));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("die Verbindung wird eingerichtet und der Token verschlüsselt abgelegt", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
assert.equal(verbindung.name, "Heimserver", "der Servername kommt vom Server");
|
||||||
|
assert.equal(verbindung.serverUserName, "florian");
|
||||||
|
|
||||||
|
const roh = await prisma.mediaServerConnection.findUniqueOrThrow({
|
||||||
|
where: { id: verbindung.id },
|
||||||
|
select: { token: true },
|
||||||
|
});
|
||||||
|
assert.notEqual(roh.token, JELLYFIN_TOKEN, "der Token steht nicht im Klartext in der Datenbank");
|
||||||
|
assert.equal(decryptSecret(roh.token), JELLYFIN_TOKEN);
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fehlt UserData, sagt die Prüfung das deutlich", async () => {
|
||||||
|
// So verhält sich Jellyfin bei Anmeldung per API-Key: Bestand ja,
|
||||||
|
// Gesehen-Status nein — und zwar ohne Fehlermeldung.
|
||||||
|
const jf = installJellyfinStub({ ohneUserData: true });
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
const pruefung = await testConnection(userId, verbindung.id);
|
||||||
|
|
||||||
|
assert.equal(pruefung.hasUserData, false, "das Fehlen wird erkannt, statt stumm zu bleiben");
|
||||||
|
assert.ok(pruefung.itemCount > 0, "der Bestand kommt trotzdem an");
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("eine abgelehnte Anmeldung wird gemeldet", async () => {
|
||||||
|
const jf = installJellyfinStub({ abweisen: true });
|
||||||
|
try {
|
||||||
|
await assert.rejects(() => verbinden(), /abgelehnt/);
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Bestand wird zugeordnet, Unzuordenbares landet im Bericht", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
|
||||||
|
// Nur der eine Film ist in der Bibliothek — der Rest soll nicht ungefragt
|
||||||
|
// hineinwandern.
|
||||||
|
await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
|
||||||
|
const bericht = await syncConnection(userId, verbindung.id);
|
||||||
|
|
||||||
|
assert.equal(bericht.gelesen, 7, "alle Items wurden gelesen");
|
||||||
|
assert.equal(bericht.aufgenommen, 0, "ohne ausdrückliche Erlaubnis wird nichts aufgenommen");
|
||||||
|
assert.equal(bericht.bestandGesetzt, 1, "nur der vorhandene Eintrag bekommt „hab ich“");
|
||||||
|
|
||||||
|
const eintrag = await prisma.libraryEntry.findFirstOrThrow({
|
||||||
|
where: { userId, titleId: movieTitleId },
|
||||||
|
select: { owned: true, ownedSource: true },
|
||||||
|
});
|
||||||
|
assert.equal(eintrag.owned, true);
|
||||||
|
assert.equal(eintrag.ownedSource, "Jellyfin");
|
||||||
|
|
||||||
|
const gruende = bericht.nichtZugeordnet.map((eintrag) => eintrag.grund);
|
||||||
|
assert.ok(
|
||||||
|
gruende.some((grund) => grund.includes("keine TMDB")),
|
||||||
|
"das Urlaubsvideo ohne Kennung steht im Bericht",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
gruende.some((grund) => grund.includes("nicht im Katalog")),
|
||||||
|
"der fremde Film steht im Bericht",
|
||||||
|
);
|
||||||
|
// Die zwei Dateien zum fremden Film stehen als ein Eintrag da.
|
||||||
|
assert.equal(
|
||||||
|
bericht.nichtZugeordnet.length,
|
||||||
|
2,
|
||||||
|
"zusammengefasst: ein fremder Film und ein Video ohne Kennung",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mit Erlaubnis werden fehlende Titel aufgenommen", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
const bericht = await syncConnection(userId, verbindung.id, { aufnehmen: true });
|
||||||
|
|
||||||
|
assert.equal(bericht.aufgenommen, 3, "Serie und beide Filme kommen dazu");
|
||||||
|
|
||||||
|
const eintraege = await prisma.libraryEntry.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { titleId: true, status: true, owned: true, ownedSource: true },
|
||||||
|
});
|
||||||
|
assert.equal(eintraege.length, 3);
|
||||||
|
assert.ok(eintraege.every((eintrag) => eintrag.status === "WATCHLIST"));
|
||||||
|
assert.ok(eintraege.every((eintrag) => eintrag.owned && eintrag.ownedSource === "Jellyfin"));
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gesehene Filme und Episoden werden mit dem Termin aus Jellyfin übernommen", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
await syncConnection(userId, verbindung.id, { aufnehmen: true });
|
||||||
|
|
||||||
|
const sichtungen = await prisma.watch.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { titleId: true, episodeId: true, watchedAt: true, source: true, minutes: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sichtungen.length, 2, "ein Film und eine Episode wurden gesehen");
|
||||||
|
assert.ok(sichtungen.every((sichtung) => sichtung.source === "JELLYFIN"));
|
||||||
|
|
||||||
|
const film = sichtungen.find((sichtung) => sichtung.episodeId === null);
|
||||||
|
assert.ok(film, "der gesehene Film ist dabei");
|
||||||
|
assert.equal(
|
||||||
|
film.watchedAt.toISOString(),
|
||||||
|
new Date("2026-07-14T20:15:00.000Z").toISOString(),
|
||||||
|
"der Termin kommt aus Jellyfin, nicht von jetzt",
|
||||||
|
);
|
||||||
|
assert.ok(film.minutes > 0, "die Laufzeit wird angerechnet");
|
||||||
|
|
||||||
|
const episode = sichtungen.find((sichtung) => sichtung.episodeId !== null);
|
||||||
|
assert.ok(episode);
|
||||||
|
assert.equal(
|
||||||
|
episode.watchedAt.toISOString(),
|
||||||
|
new Date("2026-08-01T21:00:00.000Z").toISOString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Der ungesehene Film bleibt ungesehen.
|
||||||
|
const ungesehen = sichtungen.filter((sichtung) => sichtung.titleId === movie2TitleId);
|
||||||
|
assert.equal(ungesehen.length, 0);
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ein zweiter Lauf legt nichts doppelt an", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
|
||||||
|
const erster = await syncConnection(userId, verbindung.id, { aufnehmen: true });
|
||||||
|
assert.equal(erster.sichtungenAngelegt, 2);
|
||||||
|
|
||||||
|
const zweiter = await syncConnection(userId, verbindung.id, { aufnehmen: true });
|
||||||
|
assert.equal(zweiter.sichtungenAngelegt, 0, "beim zweiten Mal gibt es nichts Neues");
|
||||||
|
assert.equal(zweiter.aufgenommen, 0);
|
||||||
|
|
||||||
|
assert.equal(await prisma.watch.count({ where: { userId } }), 2);
|
||||||
|
assert.equal(await prisma.libraryEntry.count({ where: { userId } }), 3);
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("der Abgleich löscht nichts, was in Flimmer steht", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
|
||||||
|
// In Flimmer als gesehen markiert — in Jellyfin steht dieser Film auf
|
||||||
|
// ungesehen. Der Abgleich darf ihn nicht zurücksetzen.
|
||||||
|
const eintrag = await addToLibrary(userId, movie2TitleId, "WATCHLIST");
|
||||||
|
await markMovieWatched(userId, eintrag.id);
|
||||||
|
const vorher = await prisma.watch.count({ where: { userId } });
|
||||||
|
|
||||||
|
await syncConnection(userId, verbindung.id, { aufnehmen: true });
|
||||||
|
|
||||||
|
const eigene = await prisma.watch.findMany({
|
||||||
|
where: { userId, source: "FLIMMER" },
|
||||||
|
select: { titleId: true },
|
||||||
|
});
|
||||||
|
assert.equal(eigene.length, vorher, "die eigene Sichtung steht unverändert da");
|
||||||
|
assert.equal(eigene[0]!.titleId, movie2TitleId);
|
||||||
|
|
||||||
|
const nachher = await prisma.libraryEntry.findFirstOrThrow({
|
||||||
|
where: { userId, titleId: movie2TitleId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
assert.notEqual(nachher.status, "WATCHLIST", "der Status aus Flimmer bleibt erhalten");
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("eine gelöschte Verbindung nimmt ihre Zuordnungen mit, nicht die Sichtungen", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
await syncConnection(userId, verbindung.id, { aufnehmen: true });
|
||||||
|
|
||||||
|
assert.ok((await prisma.mediaServerItem.count({ where: { connectionId: verbindung.id } })) > 0);
|
||||||
|
const sichtungenVorher = await prisma.watch.count({ where: { userId } });
|
||||||
|
|
||||||
|
await deleteConnection(userId, verbindung.id);
|
||||||
|
|
||||||
|
assert.equal(await prisma.mediaServerItem.count({ where: { connectionId: verbindung.id } }), 0);
|
||||||
|
assert.equal(
|
||||||
|
await prisma.watch.count({ where: { userId } }),
|
||||||
|
sichtungenVorher,
|
||||||
|
"die übernommenen Sichtungen bleiben — sie gehören jetzt dem Nutzer",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fremde Verbindungen sind unerreichbar", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
const fremderId = (
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { email: "test-mediaserver-fremd@flimmer.invalid", name: "Fremd" },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(() => syncConnection(fremderId, verbindung.id), /gibt es nicht/);
|
||||||
|
await assert.rejects(() => deleteConnection(fremderId, verbindung.id), /gibt es nicht/);
|
||||||
|
} finally {
|
||||||
|
await prisma.user.delete({ where: { id: fremderId } });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Nachladen unbekannter Titel (#16) ---------------------------------------
|
||||||
|
|
||||||
|
test("der Bericht fasst Dubletten zusammen und nennt Typ und Kennung", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
const bericht = await syncConnection(userId, verbindung.id);
|
||||||
|
|
||||||
|
// Zwei Jellyfin-Dateien zeigen auf denselben fremden Film.
|
||||||
|
const fremd = bericht.nichtZugeordnet.filter((eintrag) => eintrag.tmdbId === 424242);
|
||||||
|
assert.equal(fremd.length, 1, "derselbe Titel steht nur einmal in der Liste");
|
||||||
|
assert.equal(fremd[0]!.dateien, 2, "beide Dateien werden gezaehlt");
|
||||||
|
assert.equal(fremd[0]!.typ, "MOVIE");
|
||||||
|
|
||||||
|
// Das Urlaubsvideo hat keine Kennung — es steht drin, aber ohne Typ.
|
||||||
|
const ohneKennung = bericht.nichtZugeordnet.filter((eintrag) => eintrag.tmdbId === null);
|
||||||
|
assert.equal(ohneKennung.length, 1);
|
||||||
|
assert.equal(ohneKennung[0]!.typ, null, "ohne Kennung laesst sich nichts holen");
|
||||||
|
|
||||||
|
assert.equal(bericht.weitereNichtZugeordnet, 0, "nichts abgeschnitten");
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ausgewaehlte Titel werden aus TMDB nachgeladen", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
await syncConnection(userId, verbindung.id);
|
||||||
|
|
||||||
|
// MOVIE_2_ID kennt der TMDB-Stub, steht aber in diesem Lauf nicht in der
|
||||||
|
// Bibliothek — genau der Fall, den #16 loest.
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
|
||||||
|
|
||||||
|
const ergebnis = await importUnassigned(userId, verbindung.id, [
|
||||||
|
{ tmdbId: MOVIE_2_ID, typ: "MOVIE" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(ergebnis.nachgeladen, 1);
|
||||||
|
assert.deepEqual(ergebnis.fehler, []);
|
||||||
|
assert.equal(ergebnis.uebrig, 0);
|
||||||
|
|
||||||
|
const titel = await prisma.title.findFirst({ where: { tmdbId: MOVIE_2_ID } });
|
||||||
|
assert.ok(titel, "der Titel steht jetzt im Katalog");
|
||||||
|
|
||||||
|
const eintrag = await prisma.libraryEntry.findFirstOrThrow({
|
||||||
|
where: { userId, titleId: titel.id },
|
||||||
|
select: { status: true, owned: true, ownedSource: true },
|
||||||
|
});
|
||||||
|
assert.equal(eintrag.status, "WATCHLIST");
|
||||||
|
assert.equal(eintrag.owned, true, "was auf dem Server liegt, ist vorhanden");
|
||||||
|
assert.equal(eintrag.ownedSource, "Jellyfin");
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ein einzelner Fehlschlag bricht den Import nicht ab", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
|
||||||
|
|
||||||
|
const ergebnis = await importUnassigned(userId, verbindung.id, [
|
||||||
|
// Diese Kennung kennt der Stub nicht — sie muss scheitern.
|
||||||
|
{ tmdbId: 424242, typ: "MOVIE" },
|
||||||
|
{ tmdbId: MOVIE_2_ID, typ: "MOVIE" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(ergebnis.nachgeladen, 1, "der zweite Titel kommt trotzdem an");
|
||||||
|
assert.equal(ergebnis.fehler.length, 1);
|
||||||
|
assert.ok(ergebnis.fehler[0]!.includes("424242"));
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nachgeladene Titel verschwinden aus der Auswahlliste", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
await syncConnection(userId, verbindung.id);
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
|
||||||
|
|
||||||
|
const vorher = await gespeicherterBericht(verbindung.id);
|
||||||
|
assert.ok(vorher.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242));
|
||||||
|
|
||||||
|
await importUnassigned(userId, verbindung.id, [{ tmdbId: MOVIE_2_ID, typ: "MOVIE" }]);
|
||||||
|
// Der fremde Film wurde nicht ausgewaehlt und bleibt stehen …
|
||||||
|
const nachher = await gespeicherterBericht(verbindung.id);
|
||||||
|
assert.ok(nachher.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242));
|
||||||
|
|
||||||
|
// Ein Fehlschlag laesst den Eintrag dagegen stehen: TMDB kennt 424242
|
||||||
|
// nicht, und wer es spaeter erneut versuchen will, soll ihn noch finden.
|
||||||
|
const misslungen = await importUnassigned(userId, verbindung.id, [
|
||||||
|
{ tmdbId: 424242, typ: "MOVIE" },
|
||||||
|
]);
|
||||||
|
assert.equal(misslungen.nachgeladen, 0);
|
||||||
|
assert.equal(misslungen.fehler.length, 1);
|
||||||
|
|
||||||
|
const zuletzt = await gespeicherterBericht(verbindung.id);
|
||||||
|
assert.ok(
|
||||||
|
zuletzt.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242),
|
||||||
|
"was nicht geholt werden konnte, bleibt in der Liste",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fremde Verbindungen lassen sich nicht befuellen", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
const fremderId = (
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { email: "test-mediaserver-import@flimmer.invalid", name: "Fremd" },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => importUnassigned(fremderId, verbindung.id, [{ tmdbId: MOVIE_2_ID, typ: "MOVIE" }]),
|
||||||
|
/gibt es nicht/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await prisma.user.delete({ where: { id: fremderId } });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function gespeicherterBericht(connectionId: string) {
|
||||||
|
const verbindung = await prisma.mediaServerConnection.findUniqueOrThrow({
|
||||||
|
where: { id: connectionId },
|
||||||
|
select: { lastSyncReport: true },
|
||||||
|
});
|
||||||
|
return verbindung.lastSyncReport as unknown as {
|
||||||
|
nichtZugeordnet: { tmdbId: number | null }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import "./setup";
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { after, before, beforeEach } from "node:test";
|
||||||
|
|
||||||
|
import { prisma } from "../src/lib/db";
|
||||||
|
import { deletePasskey, listPasskeys, renamePasskey } from "../src/lib/passkeys";
|
||||||
|
import { env } from "../src/lib/env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Verwaltung der Passkeys und die Sperre gegen das Aussperren.
|
||||||
|
*
|
||||||
|
* Die eigentliche WebAuthn-Prüfung braucht einen echten Authenticator und
|
||||||
|
* läuft deshalb im Klickweg mit virtuellem Authenticator, nicht hier.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MIT_PASSWORT = "test-passkey-pw@flimmer.invalid";
|
||||||
|
const OHNE_PASSWORT = "test-passkey-nopw@flimmer.invalid";
|
||||||
|
|
||||||
|
let mitPasswortId: string;
|
||||||
|
let ohnePasswortId: string;
|
||||||
|
let zaehler = 0;
|
||||||
|
|
||||||
|
async function passkeyAnlegen(userId: string, name: string) {
|
||||||
|
zaehler += 1;
|
||||||
|
return prisma.passkey.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
credentialId: `test-credential-${zaehler}-${Date.now()}`,
|
||||||
|
publicKey: Buffer.from([1, 2, 3]),
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aufraeumen() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: { in: [MIT_PASSWORT, OHNE_PASSWORT] } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await aufraeumen();
|
||||||
|
mitPasswortId = (
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { email: MIT_PASSWORT, name: "Mit Passwort", passwordHash: "$2a$10$platzhalter" },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
ohnePasswortId = (
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { email: OHNE_PASSWORT, name: "Ohne Passwort" },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.passkey.deleteMany({ where: { userId: { in: [mitPasswortId, ohnePasswortId] } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await aufraeumen();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("die RP ID kommt aus APP_URL, nicht aus dem Host-Header", () => {
|
||||||
|
// Ein Passkey haengt fest an diesem Namen — hinter einem Reverse Proxy waere
|
||||||
|
// der Host-Header der falsche Ort, ihn herzunehmen.
|
||||||
|
assert.equal(env.passkeys.rpId, new URL(env.appUrl).hostname);
|
||||||
|
assert.equal(env.passkeys.rpId, "localhost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ueber localhost sind Passkeys erlaubt, ueber nacktes HTTP nicht", () => {
|
||||||
|
assert.equal(env.passkeys.isAvailable, true, "localhost gilt als sicherer Kontext");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Passkeys lassen sich auflisten, umbenennen und entfernen", async () => {
|
||||||
|
const passkey = await passkeyAnlegen(mitPasswortId, "Erstes Geraet");
|
||||||
|
|
||||||
|
const liste = await listPasskeys(mitPasswortId);
|
||||||
|
assert.equal(liste.length, 1);
|
||||||
|
assert.equal(liste[0]!.name, "Erstes Geraet");
|
||||||
|
assert.equal(liste[0]!.lastUsedAt, null, "frisch angelegt, noch nicht benutzt");
|
||||||
|
|
||||||
|
await renamePasskey(mitPasswortId, passkey.id, " Yubikey am Schluesselbund ");
|
||||||
|
assert.equal(
|
||||||
|
(await listPasskeys(mitPasswortId))[0]!.name,
|
||||||
|
"Yubikey am Schluesselbund",
|
||||||
|
"Leerraum wird abgeschnitten",
|
||||||
|
);
|
||||||
|
|
||||||
|
await deletePasskey(mitPasswortId, passkey.id);
|
||||||
|
assert.equal((await listPasskeys(mitPasswortId)).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fremde Passkeys sind unerreichbar", async () => {
|
||||||
|
const fremd = await passkeyAnlegen(ohnePasswortId, "Fremdes Geraet");
|
||||||
|
|
||||||
|
await assert.rejects(() => renamePasskey(mitPasswortId, fremd.id, "Meiner"), /gibt es nicht/);
|
||||||
|
await assert.rejects(() => deletePasskey(mitPasswortId, fremd.id), /gibt es nicht/);
|
||||||
|
|
||||||
|
// Und er steht unveraendert beim Eigentuemer.
|
||||||
|
const liste = await listPasskeys(ohnePasswortId);
|
||||||
|
assert.equal(liste.length, 1);
|
||||||
|
assert.equal(liste[0]!.name, "Fremdes Geraet");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("der letzte Anmeldeweg laesst sich nicht entfernen", async () => {
|
||||||
|
// Konto ohne Passwort, nur ein Passkey: das Entfernen wuerde aussperren.
|
||||||
|
const einziger = await passkeyAnlegen(ohnePasswortId, "Einziger Weg");
|
||||||
|
await assert.rejects(
|
||||||
|
() => deletePasskey(ohnePasswortId, einziger.id),
|
||||||
|
/letzter Anmeldeweg/,
|
||||||
|
"ohne anderen Weg bleibt der Passkey stehen",
|
||||||
|
);
|
||||||
|
assert.equal((await listPasskeys(ohnePasswortId)).length, 1);
|
||||||
|
|
||||||
|
// Mit einem zweiten Passkey geht es dann.
|
||||||
|
await passkeyAnlegen(ohnePasswortId, "Zweiter Weg");
|
||||||
|
await deletePasskey(ohnePasswortId, einziger.id);
|
||||||
|
assert.equal((await listPasskeys(ohnePasswortId)).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mit Passwort darf auch der einzige Passkey weg", async () => {
|
||||||
|
const passkey = await passkeyAnlegen(mitPasswortId, "Einziger Passkey");
|
||||||
|
await deletePasskey(mitPasswortId, passkey.id);
|
||||||
|
assert.equal((await listPasskeys(mitPasswortId)).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("das Loeschen eines Kontos nimmt seine Passkeys mit", async () => {
|
||||||
|
const email = "test-passkey-weg@flimmer.invalid";
|
||||||
|
await prisma.user.deleteMany({ where: { email } });
|
||||||
|
const user = await prisma.user.create({ data: { email, name: "Weg" }, select: { id: true } });
|
||||||
|
await passkeyAnlegen(user.id, "Verschwindet");
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id: user.id } });
|
||||||
|
assert.equal(await prisma.passkey.count({ where: { userId: user.id } }), 0);
|
||||||
|
});
|
||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
addTitle,
|
addTitle,
|
||||||
createOrder,
|
createOrder,
|
||||||
createUniverse,
|
createUniverse,
|
||||||
|
deleteOrder,
|
||||||
getAccess,
|
getAccess,
|
||||||
getUniverse,
|
getUniverse,
|
||||||
listUniverses,
|
listUniverses,
|
||||||
|
setDefaultOrder,
|
||||||
setOrderPositions,
|
setOrderPositions,
|
||||||
shareUniverse,
|
shareUniverse,
|
||||||
trashUniverse,
|
trashUniverse,
|
||||||
@@ -280,3 +282,72 @@ test("gelöschte Universen verschwinden aus der Übersicht", async () => {
|
|||||||
const list = await listUniverses(ownerId);
|
const list = await listUniverses(ownerId);
|
||||||
assert.equal(list.find((item) => item.id === universe.id), undefined);
|
assert.equal(list.find((item) => item.id === universe.id), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Standardreihenfolge ------------------------------------------------------
|
||||||
|
|
||||||
|
test("die Standardreihenfolge lässt sich umstellen und bleibt eindeutig", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Standardwechsel");
|
||||||
|
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||||
|
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||||
|
|
||||||
|
// Frisch angelegt führt die Erscheinungsreihenfolge.
|
||||||
|
const vorher = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.equal(vorher?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
|
||||||
|
|
||||||
|
await setDefaultOrder(ownerId, order.id);
|
||||||
|
|
||||||
|
// Ohne Angabe in der Adresse erscheint jetzt die eigene Reihenfolge.
|
||||||
|
const nachher = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.equal(nachher?.activeOrderId, order.id);
|
||||||
|
|
||||||
|
const standards = nachher!.orders.filter((o) => o.isDefault);
|
||||||
|
assert.equal(standards.length, 1, "es steht immer genau ein Standard");
|
||||||
|
assert.equal(standards[0]!.id, order.id, "und zwar der zuletzt gesetzte");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("die Adresse schlägt die Standardreihenfolge", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Adressvorrang");
|
||||||
|
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||||
|
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||||
|
await setDefaultOrder(ownerId, order.id);
|
||||||
|
|
||||||
|
const release = (await getUniverse(ownerId, universe.id))!.orders.find(
|
||||||
|
(o) => o.kind === "RELEASE",
|
||||||
|
)!;
|
||||||
|
const view = await getUniverse(ownerId, universe.id, release.id);
|
||||||
|
|
||||||
|
assert.equal(view?.activeOrderId, release.id, "die angeforderte gewinnt gegen den Standard");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wird die Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Rueckfall");
|
||||||
|
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||||
|
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||||
|
await setDefaultOrder(ownerId, order.id);
|
||||||
|
|
||||||
|
await deleteOrder(ownerId, order.id);
|
||||||
|
|
||||||
|
const view = await getUniverse(ownerId, universe.id);
|
||||||
|
const standards = view!.orders.filter((o) => o.isDefault);
|
||||||
|
assert.equal(standards.length, 1, "das Universum bleibt nicht ohne Standard zurück");
|
||||||
|
assert.equal(standards[0]!.kind, "RELEASE");
|
||||||
|
assert.equal(view?.activeOrderId, standards[0]!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("den Standard setzen darf nur, wer schreiben darf", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Rechteprobe");
|
||||||
|
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||||
|
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||||
|
|
||||||
|
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "READ");
|
||||||
|
await assert.rejects(() => setDefaultOrder(guestId, order.id), /nur ansehen/);
|
||||||
|
|
||||||
|
// Der Standard steht danach unverändert auf der Erscheinungsreihenfolge.
|
||||||
|
const unveraendert = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.equal(unveraendert?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
|
||||||
|
|
||||||
|
// Mit Schreibrecht geht es dann.
|
||||||
|
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "WRITE");
|
||||||
|
await setDefaultOrder(guestId, order.id);
|
||||||
|
assert.equal((await getUniverse(guestId, universe.id))?.activeOrderId, order.id);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Flimmer aktualisieren.
|
||||||
|
#
|
||||||
|
# ./update.sh sichern, holen, bauen, neu starten
|
||||||
|
# ./update.sh --ohne-backup ohne Datenbanksicherung
|
||||||
|
# ./update.sh --ohne-pull nur neu bauen und starten (kein git pull)
|
||||||
|
# ./update.sh --hilfe
|
||||||
|
#
|
||||||
|
# Grundhaltung: Erst sichern, dann anfassen. Bricht etwas ab, steht am Ende,
|
||||||
|
# wie der Weg zurück aussieht — mit dem konkreten Befehl und der konkreten
|
||||||
|
# Sicherungsdatei, nicht bloß dem Rat, ein Backup zu haben.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Ausgabe -----------------------------------------------------------------
|
||||||
|
|
||||||
|
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||||
|
AKZENT=$'\033[38;5;214m'; GUT=$'\033[32m'; WARN=$'\033[33m'
|
||||||
|
FEHLER=$'\033[31m'; LEISE=$'\033[2m'; AUS=$'\033[0m'
|
||||||
|
else
|
||||||
|
AKZENT=""; GUT=""; WARN=""; FEHLER=""; LEISE=""; AUS=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
schritt() { printf '\n%s▸ %s%s\n' "$AKZENT" "$1" "$AUS"; }
|
||||||
|
info() { printf ' %s\n' "$1"; }
|
||||||
|
leise() { printf ' %s%s%s\n' "$LEISE" "$1" "$AUS"; }
|
||||||
|
gut() { printf ' %s✓ %s%s\n' "$GUT" "$1" "$AUS"; }
|
||||||
|
warnung() { printf ' %s! %s%s\n' "$WARN" "$1" "$AUS"; }
|
||||||
|
abbruch() { printf '\n%s✗ %s%s\n' "$FEHLER" "$1" "$AUS" >&2; exit 1; }
|
||||||
|
|
||||||
|
hilfe() {
|
||||||
|
cat <<'ENDE'
|
||||||
|
Flimmer aktualisieren
|
||||||
|
|
||||||
|
./update.sh [OPTIONEN]
|
||||||
|
|
||||||
|
Ablauf: Datenbank sichern → Änderungen holen → Image bauen → neu starten →
|
||||||
|
warten, bis die Instanz wieder antwortet. Die Datenbankmigrationen spielt der
|
||||||
|
Container beim Start selbst ein.
|
||||||
|
|
||||||
|
Geprüft wird zuerst http://127.0.0.1:APP_PORT/api/health. Ist der Port gar
|
||||||
|
nicht auf dem Host veröffentlicht — üblich hinter einem Reverse Proxy —,
|
||||||
|
zählt stattdessen der Healthcheck des Containers.
|
||||||
|
|
||||||
|
Optionen:
|
||||||
|
--ohne-backup Keine Sicherung anlegen (nicht empfohlen)
|
||||||
|
--ohne-pull Nichts aus dem Repository holen, nur neu bauen und starten
|
||||||
|
--behalte N Wie viele Sicherungen aufgehoben werden (Standard: 10)
|
||||||
|
--hilfe, -h Diese Übersicht
|
||||||
|
|
||||||
|
Die Sicherungen liegen unter backups/ und sind gzip-komprimierte pg_dumps.
|
||||||
|
ENDE
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Optionen ----------------------------------------------------------------
|
||||||
|
|
||||||
|
MIT_BACKUP=1
|
||||||
|
MIT_PULL=1
|
||||||
|
BEHALTE=10
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--ohne-backup) MIT_BACKUP=0 ;;
|
||||||
|
--ohne-pull) MIT_PULL=0 ;;
|
||||||
|
--behalte) shift; BEHALTE="${1:-10}" ;;
|
||||||
|
--hilfe|-h) hilfe; exit 0 ;;
|
||||||
|
*) abbruch "Unbekannte Option: $1 (./update.sh --hilfe)" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Voraussetzungen ---------------------------------------------------------
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
[ -f docker-compose.yml ] || abbruch "Hier liegt keine docker-compose.yml. Das Skript gehört ins Flimmer-Verzeichnis."
|
||||||
|
[ -f .env ] || abbruch "Es fehlt die .env. Lege sie an: cp .env.example .env"
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || abbruch "docker ist nicht installiert oder nicht im Pfad."
|
||||||
|
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker-compose"
|
||||||
|
else
|
||||||
|
abbruch "Weder 'docker compose' noch 'docker-compose' gefunden."
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker info >/dev/null 2>&1 || abbruch "Der Docker-Dienst antwortet nicht. Läuft er?"
|
||||||
|
|
||||||
|
# Werte aus der .env lesen, ohne sie auszuführen.
|
||||||
|
env_wert() {
|
||||||
|
local schluessel="$1" standard="${2:-}"
|
||||||
|
local zeile
|
||||||
|
zeile=$(grep -E "^[[:space:]]*${schluessel}=" .env 2>/dev/null | tail -1 || true)
|
||||||
|
if [ -z "$zeile" ]; then printf '%s' "$standard"; return; fi
|
||||||
|
zeile="${zeile#*=}"
|
||||||
|
zeile="${zeile%\"}"; zeile="${zeile#\"}"
|
||||||
|
zeile="${zeile%\'}"; zeile="${zeile#\'}"
|
||||||
|
printf '%s' "$zeile"
|
||||||
|
}
|
||||||
|
|
||||||
|
DB_USER=$(env_wert POSTGRES_USER flimmer)
|
||||||
|
DB_NAME=$(env_wert POSTGRES_DB flimmer)
|
||||||
|
PORT=$(env_wert APP_PORT 3000)
|
||||||
|
|
||||||
|
printf '%s\n' "Flimmer aktualisieren"
|
||||||
|
leise "Verzeichnis: $(pwd)"
|
||||||
|
leise "Compose: $COMPOSE"
|
||||||
|
|
||||||
|
# --- Stand festhalten --------------------------------------------------------
|
||||||
|
|
||||||
|
IST_GIT=0
|
||||||
|
VORHER_REF=""
|
||||||
|
if [ -d .git ] && command -v git >/dev/null 2>&1; then
|
||||||
|
IST_GIT=1
|
||||||
|
VORHER_REF=$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")
|
||||||
|
fi
|
||||||
|
|
||||||
|
VORHER_VERSION=$(grep -m1 '"version"' package.json 2>/dev/null | sed 's/.*"version"[^"]*"\([^"]*\)".*/\1/' || echo "?")
|
||||||
|
leise "Aktuell: Version $VORHER_VERSION${VORHER_REF:+ (${VORHER_REF})}"
|
||||||
|
|
||||||
|
# --- Sicherung ---------------------------------------------------------------
|
||||||
|
|
||||||
|
SICHERUNG=""
|
||||||
|
if [ "$MIT_BACKUP" -eq 1 ]; then
|
||||||
|
schritt "Datenbank sichern"
|
||||||
|
|
||||||
|
# -q gibt es in beiden Compose-Fassungen; --status erst ab v2.
|
||||||
|
if [ -z "$($COMPOSE ps -q db 2>/dev/null)" ]; then
|
||||||
|
warnung "Die Datenbank läuft nicht — es gibt also auch nichts zu sichern."
|
||||||
|
else
|
||||||
|
mkdir -p backups
|
||||||
|
SICHERUNG="backups/flimmer-$(date +%Y-%m-%d-%H%M%S).sql.gz"
|
||||||
|
|
||||||
|
if $COMPOSE exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists \
|
||||||
|
| gzip > "$SICHERUNG" 2>/dev/null && [ -s "$SICHERUNG" ]; then
|
||||||
|
gut "$SICHERUNG ($(du -h "$SICHERUNG" | cut -f1))"
|
||||||
|
else
|
||||||
|
rm -f "$SICHERUNG"
|
||||||
|
SICHERUNG=""
|
||||||
|
abbruch "Die Sicherung ist fehlgeschlagen. Abbruch — ohne Sicherung wird hier nichts aktualisiert.
|
||||||
|
Wenn du es trotzdem willst: ./update.sh --ohne-backup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ältere Sicherungen aufräumen, die jüngsten bleiben.
|
||||||
|
if [ "$BEHALTE" -gt 0 ]; then
|
||||||
|
ANZAHL=$(ls -1 backups/flimmer-*.sql.gz 2>/dev/null | wc -l)
|
||||||
|
if [ "$ANZAHL" -gt "$BEHALTE" ]; then
|
||||||
|
ls -1t backups/flimmer-*.sql.gz | tail -n +$((BEHALTE + 1)) | xargs -r rm --
|
||||||
|
leise "Ältere Sicherungen entfernt, die letzten $BEHALTE bleiben."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warnung "Ohne Sicherung — auf eigenes Risiko."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Änderungen holen --------------------------------------------------------
|
||||||
|
|
||||||
|
if [ "$MIT_PULL" -eq 1 ]; then
|
||||||
|
schritt "Änderungen holen"
|
||||||
|
|
||||||
|
if [ "$IST_GIT" -eq 0 ]; then
|
||||||
|
warnung "Kein Git-Repository — übersprungen."
|
||||||
|
elif [ -n "$(git status --porcelain 2>/dev/null)" ]; then
|
||||||
|
warnung "Es gibt lokale Änderungen; git pull wird übersprungen."
|
||||||
|
leise "Ansehen mit: git status"
|
||||||
|
else
|
||||||
|
ZWEIG=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
if git pull --ff-only 2>&1 | sed 's/^/ /'; then
|
||||||
|
NACHHER_REF=$(git rev-parse --short HEAD)
|
||||||
|
if [ "$NACHHER_REF" = "$VORHER_REF" ]; then
|
||||||
|
gut "Schon auf dem neuesten Stand ($ZWEIG)."
|
||||||
|
else
|
||||||
|
gut "$VORHER_REF → $NACHHER_REF"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
abbruch "git pull ist fehlgeschlagen. Vermutlich sind Zweig und Gegenstelle auseinandergelaufen."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEUE_VERSION=$(grep -m1 '"version"' package.json 2>/dev/null | sed 's/.*"version"[^"]*"\([^"]*\)".*/\1/' || echo "?")
|
||||||
|
|
||||||
|
# --- Bauen und starten -------------------------------------------------------
|
||||||
|
|
||||||
|
schritt "Image bauen"
|
||||||
|
info "Das dauert beim ersten Mal einige Minuten."
|
||||||
|
$COMPOSE build 2>&1 | sed 's/^/ /' || abbruch "Der Build ist fehlgeschlagen. Die laufende Instanz ist unangetastet."
|
||||||
|
gut "Gebaut."
|
||||||
|
|
||||||
|
schritt "Neu starten"
|
||||||
|
$COMPOSE up -d 2>&1 | sed 's/^/ /' || abbruch "Der Start ist fehlgeschlagen. Log ansehen: $COMPOSE logs -f app"
|
||||||
|
gut "Container laufen."
|
||||||
|
|
||||||
|
# --- Warten, bis die Instanz antwortet ---------------------------------------
|
||||||
|
|
||||||
|
schritt "Auf die Instanz warten"
|
||||||
|
info "Die Datenbankmigrationen spielt der Container beim Start selbst ein."
|
||||||
|
|
||||||
|
# Der Container meldet seinen Zustand selbst — der HEALTHCHECK im Image fragt
|
||||||
|
# /api/health von innen ab. Das ist der verlässlichere Weg: hinter einem
|
||||||
|
# Reverse Proxy ist der App-Port oft gar nicht auf dem Host veröffentlicht,
|
||||||
|
# und dann sagt ein fehlschlagendes curl nichts über die Instanz aus.
|
||||||
|
# Gibt immer 0 zurück: die Funktion läuft auch im Fehlerpfad, und unter
|
||||||
|
# `set -e` dürfte eine Auskunft über den Zustand nicht selbst das Skript
|
||||||
|
# abbrechen, bevor der Weg zurück ausgegeben wurde.
|
||||||
|
container_zustand() {
|
||||||
|
local id
|
||||||
|
id=$($COMPOSE ps -q app 2>/dev/null || true)
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
printf 'kein-container'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Ohne HEALTHCHECK im Image ist .State.Health leer — dann bleibt der
|
||||||
|
# Laufzustand (running/exited) als grobe Auskunft.
|
||||||
|
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}ohne-healthcheck:{{.State.Status}}{{end}}' \
|
||||||
|
"$id" 2>/dev/null || printf 'unbekannt'
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
GESUND=0
|
||||||
|
UEBER_CONTAINER=0
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then
|
||||||
|
GESUND=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$(container_zustand)" = "healthy" ]; then
|
||||||
|
GESUND=1
|
||||||
|
UEBER_CONTAINER=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$GESUND" -eq 1 ]; then
|
||||||
|
if [ "$UEBER_CONTAINER" -eq 1 ]; then
|
||||||
|
gut "Der Container meldet sich gesund."
|
||||||
|
leise "Auf Port ${PORT} war nichts zu erreichen — das ist normal, wenn die App"
|
||||||
|
leise "nur über einen Reverse Proxy erreichbar ist und den Port nicht veröffentlicht."
|
||||||
|
else
|
||||||
|
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true)
|
||||||
|
gut "Flimmer antwortet auf Port ${PORT}."
|
||||||
|
leise "$ANTWORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n%s✓ Fertig — Version %s%s\n' "$GUT" "$NEUE_VERSION" "$AUS"
|
||||||
|
if [ "$VORHER_VERSION" != "$NEUE_VERSION" ]; then
|
||||||
|
info "Was sich geändert hat, steht in CHANGELOG.md."
|
||||||
|
fi
|
||||||
|
if [ -n "$SICHERUNG" ]; then
|
||||||
|
leise "Sicherung: $SICHERUNG"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Fehlschlag: konkreter Weg zurück ----------------------------------------
|
||||||
|
|
||||||
|
printf '\n%s✗ Die Instanz antwortet nach zwei Minuten nicht.%s\n\n' "$FEHLER" "$AUS" >&2
|
||||||
|
|
||||||
|
# Der Container-Zustand grenzt ein, wo es klemmt — ohne ihn sähe ein nicht
|
||||||
|
# veröffentlichter Port genauso aus wie eine abgestürzte App.
|
||||||
|
ZUSTAND=$(container_zustand)
|
||||||
|
case "$ZUSTAND" in
|
||||||
|
kein-container)
|
||||||
|
info "Es läuft gar kein app-Container. Der Start ist offenbar fehlgeschlagen."
|
||||||
|
;;
|
||||||
|
starting)
|
||||||
|
info "Der Container startet noch. Möglicherweise dauern die Migrationen länger —"
|
||||||
|
info "sieh im Log nach und gib ihm etwas Zeit."
|
||||||
|
;;
|
||||||
|
unhealthy)
|
||||||
|
info "Der Container läuft, meldet sich aber als nicht gesund. Der Fehler liegt"
|
||||||
|
info "also in der Anwendung, nicht am Port."
|
||||||
|
;;
|
||||||
|
ohne-healthcheck:running)
|
||||||
|
info "Der Container läuft, das Image bringt aber keinen Healthcheck mit (ältere"
|
||||||
|
info "Fassung). Ob die App wirklich antwortet, verrät nur das Log."
|
||||||
|
;;
|
||||||
|
ohne-healthcheck:*)
|
||||||
|
info "Der Container ist nicht gelaufen: ${ZUSTAND#ohne-healthcheck:}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
info "Container-Zustand: ${ZUSTAND}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
info "Log ansehen:"
|
||||||
|
printf ' %s logs --tail 50 app\n\n' "$COMPOSE"
|
||||||
|
|
||||||
|
if [ -n "$SICHERUNG" ] || [ -n "$VORHER_REF" ]; then
|
||||||
|
info "Weg zurück:"
|
||||||
|
if [ "$IST_GIT" -eq 1 ] && [ -n "$VORHER_REF" ]; then
|
||||||
|
printf ' git checkout %s && %s up -d --build\n' "$VORHER_REF" "$COMPOSE"
|
||||||
|
fi
|
||||||
|
if [ -n "$SICHERUNG" ]; then
|
||||||
|
printf ' gunzip -c %s | %s exec -T db psql -U %s -d %s\n' \
|
||||||
|
"$SICHERUNG" "$COMPOSE" "$DB_USER" "$DB_NAME"
|
||||||
|
fi
|
||||||
|
printf '\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 1
|
||||||