Passkeys und die Jellyfin-Anbindung als groessere Bausteine, dazu vier Nacharbeiten aus dem Betrieb. Changelog, Release-Notiz, DEVLOG und die bebilderte Anleitung nachgezogen. Die Anleitung hat zwei neue Abschnitte samt Screenshots: „Anmelden per Passkey" und ein Bild im Jellyfin-Abschnitt. Beide Aufnahmen brauchen Gegenstellen — einen Authenticator und einen Medienserver. Damit `npm run screenshots` reproduzierbar bleibt, stellt das Skript sie jetzt selbst bereit: der virtuelle Authenticator kommt ueber CDP vom Browser, der Jellyfin-Nachbau sind ein paar Zeilen HTTP im Skript. Beide Spuren raeumt es nach der Aufnahme ueber die Oberflaeche wieder weg. Der Merkposten zum Entwicklungsmodus in CLAUDE.md war zu absolut: dass unter Playwright nichts hydratisiert, stimmt nicht durchgehend — im Screenshot-Lauf gegen denselben Dev-Server funktionierten die Klicks. Der Eintrag beschreibt jetzt das Beobachtete statt einer Regel, die nicht traegt, und nennt die Pruefung, mit der sich der Zustand feststellen laesst. Dazu neu: APP_URL muss zum aufgerufenen Port passen, sonst scheitern Passkeys am Ursprungsabgleich. Alle Screenshots neu aufgenommen — die Einstellungsseite hatte sich durch die neuen Abschnitte ohnehin geaendert.
@@ -7,6 +7,121 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.8.0] — 2026-09-02
|
||||
|
||||
Zwei größere Bausteine: **Anmeldung per Passkey** und die **Jellyfin-Anbindung**.
|
||||
Dazu vier Nacharbeiten aus dem laufenden Betrieb.
|
||||
|
||||
> ⚠️ **Beim Aktualisieren kommen zwei Migrationen mit** (`passkeys`,
|
||||
> `medienserver`). Der Container spielt sie beim Start selbst ein; `./update.sh`
|
||||
> legt vorher eine Sicherung an.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Anmeldung per Passkey (WebAuthn).** Windows Hello, Face ID, Touch ID oder
|
||||
ein Sicherheitsschlüssel — statt Passwort. Anlegen und verwalten unter
|
||||
*Einstellungen → Passkeys*, anmelden über „Mit Passkey anmelden" **ohne
|
||||
vorherige E-Mail-Eingabe**.
|
||||
|
||||
Ein Passkey ist der dritte Weg in denselben Trichter: am Ende steht dieselbe
|
||||
Sitzung wie bei Passwort und OIDC. Das Passwort bleibt daneben bestehen, und
|
||||
den letzten verbleibenden Anmeldeweg lässt Flimmer nicht entfernen.
|
||||
|
||||
Drei Dinge, die beim Selbsthosten zählen:
|
||||
- **HTTPS ist Pflicht** (Ausnahme: `localhost`). Läuft die Instanz über
|
||||
einfaches HTTP, verschwindet die Verwaltung mit einem Hinweis, statt einen
|
||||
kryptischen Browserfehler zu zeigen.
|
||||
- Der Hostname kommt aus `APP_URL`, nicht aus dem `Host`-Header — hinter
|
||||
einem Reverse Proxy hinge er sonst am falschen Namen.
|
||||
- **Ein Domainwechsel entwertet alle Passkeys.** Sie sind fest an den
|
||||
Hostnamen gebunden und müssen danach neu angelegt werden.
|
||||
|
||||
Abschaltbar über `ENABLE_PASSKEYS`.
|
||||
|
||||
- **Jellyfin anbinden** — Bestand und Sichtungen übernehmen, unter
|
||||
*Einstellungen → Medienserver*.
|
||||
|
||||
Zugeordnet wird über die **TMDB-Kennung**, die Jellyfin zu jedem Item
|
||||
mitliefert; dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut. Kein
|
||||
Raten über Titel und Jahreszahlen — die Zuordnung ist eindeutig oder sie
|
||||
findet nicht statt. Was Jellyfin ohne Kennung führt, landet im Bericht.
|
||||
|
||||
Angemeldet wird mit Benutzername und Passwort, **nicht** mit einem
|
||||
API-Schlüssel: mit einem Schlüssel liefert Jellyfin den Bestand ohne
|
||||
Gesehen-Status, und zwar ohne Fehlermeldung. Der Verbindungstest prüft das
|
||||
ausdrücklich nach und sagt es. Das Zugangstoken liegt verschlüsselt in der
|
||||
Datenbank (AES-256-GCM, Schlüssel aus `AUTH_SECRET`), das Passwort wird nicht
|
||||
gespeichert.
|
||||
|
||||
Der Abgleich ist eine **Einbahnstraße**: er ergänzt und löscht nie etwas, das
|
||||
in Flimmer steht. Fehlende Titel werden nur mit gesetztem Häkchen
|
||||
aufgenommen — im täglichen Lauf also nie. Danach läuft er im
|
||||
Hintergrundlauf mit.
|
||||
|
||||
*Grenze der Quelle:* Jellyfin kennt `PlayCount` und `LastPlayedDate`, aber
|
||||
keine Liste einzelner Sichtungen. Ein dreimal gesehener Film kommt deshalb
|
||||
mit **einem** Termin an, nicht mit drei. Das steht auch in der Anleitung.
|
||||
|
||||
Das Zurückschreiben nach Jellyfin (Stufe 3) ist noch nicht dabei; das Feld
|
||||
liegt im Datenmodell bereit.
|
||||
|
||||
- **Standardreihenfolge je Universum.** Welche Reihenfolge beim Öffnen
|
||||
erscheint, lässt sich jetzt festlegen — wer Schreibrecht hat, setzt sie für
|
||||
alle. Die Rangfolge bleibt schlicht: Adresse schlägt Standard schlägt erste.
|
||||
Über die Schnittstelle mit `PATCH /api/v1/universen/{id}`.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Rückmeldung beim Tippen** in Suche und Bibliothek. Beim Suchen wird das
|
||||
Trefferraster gedämpft und auf `aria-busy` gesetzt, der Hinweis „sucht …"
|
||||
steht jetzt auch in der Bibliothek. Die alten Treffer bleiben sichtbar —
|
||||
sie verschwinden zu lassen ließe die Liste bei jedem Tastendruck springen.
|
||||
Die Sammelverzögerung liegt einheitlich bei 250 ms.
|
||||
|
||||
- **`update.sh` erkennt jetzt den Container-Zustand.** Schlägt die Abfrage über
|
||||
den veröffentlichten Port fehl, zählt der Healthcheck des Containers —
|
||||
hinter einem Reverse Proxy ist der App-Port oft gar nicht auf dem Host
|
||||
veröffentlicht, und das Skript meldete dann einen Fehlschlag, obwohl das
|
||||
Update sauber durchgelaufen war. Im Fehlerfall nennt es außerdem den Zustand
|
||||
und ordnet ihn ein.
|
||||
|
||||
*Zur Klarstellung:* Der Changelog zu 0.7.1 führte diese Rückfallprüfung
|
||||
bereits auf. Sie war dort beschrieben, aber nicht umgesetzt — jetzt ist sie
|
||||
es.
|
||||
|
||||
- Die Entwicklungsdatenbank hat einen eigenen Compose-Projektnamen
|
||||
(`flimmer-dev`). Vorher leiteten beide Compose-Dateien ihn aus dem
|
||||
Verzeichnis ab, und ein `docker compose down -v` auf dem Produktivstack nahm
|
||||
das Volume der Entwicklungsdatenbank mit.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Ein als gesehen markierter Film ließ sich nicht mehr zurücksetzen.** Im
|
||||
Zustand „gesehen" stand statt eines Knopfes ein Etikett ohne Klickziel, und
|
||||
eine Gegenfunktion gab es nicht — Serien hatten längst einen Umschalter,
|
||||
Filme nur einen Hinweg. Die grüne Plakette ist jetzt klickbar.
|
||||
Zurückgenommen wird dabei **nur der laufende Durchlauf**: wer einen Film
|
||||
dreimal gesehen hat, verliert die früheren Sichtungen nicht.
|
||||
|
||||
- **Die Beschriftungen im Titelraster sprangen**, sobald ein Titel zweizeilig
|
||||
umbrach — die Metazeile saß dann eine Zeilenhöhe tiefer als bei den Nachbarn,
|
||||
und die Reihen waren unterschiedlich hoch. Der Titelblock belegt jetzt immer
|
||||
zwei Zeilen; in der Bibliothek wird zusätzlich der Platz des
|
||||
Fortschrittsbalkens freigehalten, den es nur bei Serien gibt.
|
||||
|
||||
### Getestet
|
||||
|
||||
78 Tests (vorher 52). Neu dazu: Passkey-Verwaltung samt Aussperrsperre, der
|
||||
Jellyfin-Abgleich gegen nachgebildete Antworten, die Standardreihenfolge samt
|
||||
Eindeutigkeit, und das Zurücknehmen einer Filmsichtung über
|
||||
Durchlaufgrenzen hinweg.
|
||||
|
||||
Zusätzlich wurden die Klickwege gegen einen **Produktionsbau** geprüft — die
|
||||
Passkey-Anmeldung mit einem virtuellen Authenticator, der Jellyfin-Abgleich
|
||||
gegen einen nachgebildeten Server samt des Falls, in dem der Gesehen-Status
|
||||
fehlt.
|
||||
|
||||
|
||||
## [0.7.1] — 2026-09-01
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -112,6 +112,13 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
||||
|
||||
**Alle sechs Etappen abgeschlossen. Gesamtzeit: 2 h 16 min.**
|
||||
|
||||
- ✅ **Nacharbeiten** (v0.7.0/0.7.1) — Docker verifiziert, „hab ich", `update.sh`. 51 min.
|
||||
- ✅ **Issue-Runde** (v0.8.0) — Passkeys, Jellyfin-Anbindung, Standardreihenfolge,
|
||||
Rückmeldung beim Tippen, Titelraster, Filmsichtung zurücknehmen. 77 min.
|
||||
|
||||
Ab v0.8.0 wird **issue-getrieben** gearbeitet: eigener Branch je Issue,
|
||||
Zeiterfassung in Gitea, Freigabe durch Florian. Details im DEVLOG.
|
||||
|
||||
### Nützliches für spätere Arbeiten
|
||||
- Suche ohne TMDB-Schlüssel ausprobieren:
|
||||
`npm run tmdb:stub` und dann
|
||||
@@ -122,16 +129,25 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
||||
das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok.
|
||||
- Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das
|
||||
Write-Werkzeug benutzen.
|
||||
- **Klickwege niemals gegen `npm run dev` prüfen.** Unter headless Chromium
|
||||
scheitert der HMR-Socket (`ERR_INVALID_HTTP_RESPONSE`), dadurch hydratisiert
|
||||
**keine** Seite und kein Knopf reagiert — ohne Seitenfehler, ohne
|
||||
fehlgeschlagene Anfrage, es passiert einfach nichts. Sieht aus wie ein Fehler
|
||||
im eigenen Code, ist aber die Umgebung. Stattdessen
|
||||
`npm run build` und `PORT=3012 npm start`, dann dagegen klicken.
|
||||
Formulare täuschen zusätzlich: die Anmeldung funktioniert auch ohne
|
||||
JavaScript, wirkt also intakt.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -103,6 +103,49 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Gesamtübersicht
|
||||
|
||||
| Etappe | Version | Dauer |
|
||||
@@ -114,8 +157,9 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
|
||||
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
|
||||
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
|
||||
| Issue-Runde — Passkeys, Jellyfin, vier Nacharbeiten | 0.8.0 | 77 min |
|
||||
|
||||
**Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16)
|
||||
**Gesamtentwicklungszeit: 4 h 24 min** (264 min, 2026-09-01 18:09 – 2026-09-02 07:25)
|
||||
|
||||
### Womit die Zeit verging
|
||||
|
||||
|
||||
@@ -256,6 +256,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
|
||||
|
||||

|
||||
@@ -301,6 +335,8 @@ 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.
|
||||
|
||||
|
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: 75 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 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 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const OUT = "docs/screenshots";
|
||||
const BASE = "http://localhost:3011";
|
||||
@@ -32,6 +33,68 @@ async function login(page) {
|
||||
await Promise.all([page.waitForURL(`${BASE}/`), page.click("button[type=submit]")]);
|
||||
}
|
||||
|
||||
/** Screenshot eines einzelnen Abschnitts statt der ganzen Seite. */
|
||||
async function ausschnitt(page, name, ueberschrift) {
|
||||
// Einblendungen wegklicken — sie legen sich sonst über den Inhalt.
|
||||
const schliessen = page.locator("[aria-live] button");
|
||||
for (let i = await schliessen.count(); i > 0; i -= 1) {
|
||||
await schliessen.first().click().catch(() => {});
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const bereich = page.locator("section", { hasText: ueberschrift }).first();
|
||||
await bereich.screenshot({ path: `${OUT}/${name}.png` });
|
||||
console.log(" ", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Jellyfin, das gerade genug kann: anmelden, Systeminfo, Bestand.
|
||||
* Die TMDB-Kennungen zeigen auf Titel aus dem Seed, damit der Bericht echte
|
||||
* Zuordnungen zeigt. Alles auf ungesehen — der Screenshot soll den Bestand
|
||||
* zeigen, nicht die Sichtungen der Beispieldaten verbiegen.
|
||||
*/
|
||||
async function starteJellyfinNachbau() {
|
||||
const items = [
|
||||
{ Id: "jf-1", Name: "Die Stille zwischen den Bildern", Type: "Movie",
|
||||
ProviderIds: { Tmdb: "9100013" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
|
||||
{ Id: "jf-2", Name: "Die Stille zwischen den Bildern II", Type: "Movie",
|
||||
ProviderIds: { Tmdb: "9100014" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
|
||||
{ Id: "jf-3", Name: "Die Vermessung des Feierabends", Type: "Movie",
|
||||
ProviderIds: { Tmdb: "9100017" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
|
||||
{ Id: "jf-4", Name: "Urlaubsvideo 2019", Type: "Movie",
|
||||
ProviderIds: {}, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
|
||||
];
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://x");
|
||||
const json = (body) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
if (url.pathname === "/Users/AuthenticateByName") {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () =>
|
||||
json({ AccessToken: "screenshot-token", User: { Id: "jf-user", Name: "florian" } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/System/Info") return json({ ServerName: "Heimserver", Version: "10.10.3" });
|
||||
if (url.pathname === "/Items") return json({ Items: items, TotalRecordCount: items.length });
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise((fertig) => server.listen(0, "127.0.0.1", fertig));
|
||||
const port = server.address().port;
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise((fertig) => server.close(fertig)),
|
||||
};
|
||||
}
|
||||
|
||||
async function shot(page, name, url, opts = {}) {
|
||||
await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" });
|
||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
|
||||
@@ -78,6 +141,64 @@ async function shot(page, name, url, opts = {}) {
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
// --- Zugang: Passkeys und Medienserver ---
|
||||
//
|
||||
// Beide brauchen Gegenstellen: einen Authenticator und einen Jellyfin-Server.
|
||||
// Statt echte Geräte vorauszusetzen, stellt das Skript sie selbst bereit —
|
||||
// der virtuelle Authenticator kommt vom Browser, der Medienserver ist ein
|
||||
// paar Zeilen HTTP hier drin. Beide Spuren werden am Ende wieder entfernt.
|
||||
{
|
||||
const jellyfin = await starteJellyfinNachbau();
|
||||
|
||||
const { ctx, page } = await session(1400, 1000, "dark");
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
await cdp.send("WebAuthn.enable");
|
||||
await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
||||
options: {
|
||||
protocol: "ctap2",
|
||||
transport: "internal",
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation: true,
|
||||
},
|
||||
});
|
||||
|
||||
await login(page);
|
||||
await page.goto(`${BASE}/einstellungen`, { waitUntil: "networkidle" });
|
||||
|
||||
// Passkey anlegen, damit die Liste nicht leer ist.
|
||||
await page.fill("input[aria-label='Name für den neuen Passkey']", "Mein Notebook");
|
||||
await page.getByRole("button", { name: "Passkey anlegen" }).click();
|
||||
await page.waitForTimeout(2500);
|
||||
await ausschnitt(page, "21-passkeys", "Passkeys");
|
||||
|
||||
// Jellyfin verbinden und einmal abgleichen.
|
||||
const medien = page.locator("section", { hasText: "Medienserver" });
|
||||
await page.fill("input[placeholder='http://jellyfin.fritz.box:8096']", jellyfin.url);
|
||||
await medien.locator("input").nth(1).fill("florian");
|
||||
await medien.locator("input").nth(2).fill("geheim");
|
||||
await page.getByRole("button", { name: "Verbinden" }).click();
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await page.getByRole("button", { name: "Jetzt abgleichen" }).click();
|
||||
await page.waitForTimeout(3500);
|
||||
await ausschnitt(page, "22-medienserver", "Medienserver");
|
||||
|
||||
// Spuren beseitigen: erst die Verbindung, dann den Passkey.
|
||||
await page.getByRole("button", { name: "Entfernen" }).first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
const passkeyWeg = page.locator("section", { hasText: "Passkeys" }).getByRole("button", { name: "Entfernen" });
|
||||
if (await passkeyWeg.count()) {
|
||||
await passkeyWeg.first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
await ctx.close();
|
||||
await jellyfin.close();
|
||||
}
|
||||
|
||||
// --- Hell ---
|
||||
{
|
||||
const { ctx, page } = await session(1400, 900, "light");
|
||||
|
||||