Release 0.8.0 #14

Merged
florian.wilde merged 1 commits from chore/release-0.8.0 into main 2026-09-02 08:36:27 +00:00
27 changed files with 455 additions and 10 deletions
+115
View File
@@ -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
+24 -8
View File
@@ -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.
+45 -1
View File
@@ -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
+36
View File
@@ -256,6 +256,40 @@ Geräten abgemeldet.
---
## Anmelden per Passkey
![Passkeys](screenshots/21-passkeys.webp)
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](screenshots/12-benachrichtigungen.webp)
@@ -301,6 +335,8 @@ Tabellenkalkulationen.
## Jellyfin anbinden
![Medienserver](screenshots/22-medienserver.webp)
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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+1 -1
View File
@@ -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",
+113
View File
@@ -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.
+121
View File
@@ -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");