diff --git a/CHANGELOG.md b/CHANGELOG.md index 396ba0c..9571c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index c1ec571..5d59b66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/DEVLOG.md b/DEVLOG.md index 936f6eb..3b9b6b2 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 `

`; 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 diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index e17a596..34d6c78 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -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. diff --git a/docs/screenshots/01-anmeldung.webp b/docs/screenshots/01-anmeldung.webp index 743ff5d..83cbf42 100644 Binary files a/docs/screenshots/01-anmeldung.webp and b/docs/screenshots/01-anmeldung.webp differ diff --git a/docs/screenshots/02-uebersicht.webp b/docs/screenshots/02-uebersicht.webp index 866f6e3..27e5fef 100644 Binary files a/docs/screenshots/02-uebersicht.webp and b/docs/screenshots/02-uebersicht.webp differ diff --git a/docs/screenshots/03-suche.webp b/docs/screenshots/03-suche.webp index 29c0aff..7207ea8 100644 Binary files a/docs/screenshots/03-suche.webp and b/docs/screenshots/03-suche.webp differ diff --git a/docs/screenshots/04-serie.webp b/docs/screenshots/04-serie.webp index 7e4a5dc..209f1f9 100644 Binary files a/docs/screenshots/04-serie.webp and b/docs/screenshots/04-serie.webp differ diff --git a/docs/screenshots/05-bibliothek.webp b/docs/screenshots/05-bibliothek.webp index f33c7b7..8fc137c 100644 Binary files a/docs/screenshots/05-bibliothek.webp and b/docs/screenshots/05-bibliothek.webp differ diff --git a/docs/screenshots/06-universen.webp b/docs/screenshots/06-universen.webp index c818e39..9d24e2e 100644 Binary files a/docs/screenshots/06-universen.webp and b/docs/screenshots/06-universen.webp differ diff --git a/docs/screenshots/07-universum.webp b/docs/screenshots/07-universum.webp index 9627667..8e17bd8 100644 Binary files a/docs/screenshots/07-universum.webp and b/docs/screenshots/07-universum.webp differ diff --git a/docs/screenshots/08-hell.webp b/docs/screenshots/08-hell.webp index 9a5c88d..099d0cb 100644 Binary files a/docs/screenshots/08-hell.webp and b/docs/screenshots/08-hell.webp differ diff --git a/docs/screenshots/09-kalender.webp b/docs/screenshots/09-kalender.webp index 60edd3b..2b68b07 100644 Binary files a/docs/screenshots/09-kalender.webp and b/docs/screenshots/09-kalender.webp differ diff --git a/docs/screenshots/10-statistik.webp b/docs/screenshots/10-statistik.webp index 5a914a4..86dc3d1 100644 Binary files a/docs/screenshots/10-statistik.webp and b/docs/screenshots/10-statistik.webp differ diff --git a/docs/screenshots/11-einstellungen.webp b/docs/screenshots/11-einstellungen.webp index c3f3a39..bcbabe5 100644 Binary files a/docs/screenshots/11-einstellungen.webp and b/docs/screenshots/11-einstellungen.webp differ diff --git a/docs/screenshots/12-benachrichtigungen.webp b/docs/screenshots/12-benachrichtigungen.webp index 0279981..149461b 100644 Binary files a/docs/screenshots/12-benachrichtigungen.webp and b/docs/screenshots/12-benachrichtigungen.webp differ diff --git a/docs/screenshots/13-api.webp b/docs/screenshots/13-api.webp index e05d346..878d874 100644 Binary files a/docs/screenshots/13-api.webp and b/docs/screenshots/13-api.webp differ diff --git a/docs/screenshots/14-daten.webp b/docs/screenshots/14-daten.webp index 52d09fe..23e1dd0 100644 Binary files a/docs/screenshots/14-daten.webp and b/docs/screenshots/14-daten.webp differ diff --git a/docs/screenshots/15-verwaltung.webp b/docs/screenshots/15-verwaltung.webp index 89d9cad..ec6cccc 100644 Binary files a/docs/screenshots/15-verwaltung.webp and b/docs/screenshots/15-verwaltung.webp differ diff --git a/docs/screenshots/16-papierkorb.webp b/docs/screenshots/16-papierkorb.webp index 17190d9..46e0add 100644 Binary files a/docs/screenshots/16-papierkorb.webp and b/docs/screenshots/16-papierkorb.webp differ diff --git a/docs/screenshots/17-mobil-uebersicht.webp b/docs/screenshots/17-mobil-uebersicht.webp index 7226359..407bcdf 100644 Binary files a/docs/screenshots/17-mobil-uebersicht.webp and b/docs/screenshots/17-mobil-uebersicht.webp differ diff --git a/docs/screenshots/18-mobil-serie.webp b/docs/screenshots/18-mobil-serie.webp index e437316..e05c2d7 100644 Binary files a/docs/screenshots/18-mobil-serie.webp and b/docs/screenshots/18-mobil-serie.webp differ diff --git a/docs/screenshots/21-passkeys.webp b/docs/screenshots/21-passkeys.webp new file mode 100644 index 0000000..eefbef2 Binary files /dev/null and b/docs/screenshots/21-passkeys.webp differ diff --git a/docs/screenshots/22-medienserver.webp b/docs/screenshots/22-medienserver.webp new file mode 100644 index 0000000..c6a4ec5 Binary files /dev/null and b/docs/screenshots/22-medienserver.webp differ diff --git a/package.json b/package.json index 920ce02..a94980e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/releases/v0.8.0.md b/releases/v0.8.0.md new file mode 100644 index 0000000..e6b83b5 --- /dev/null +++ b/releases/v0.8.0.md @@ -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. diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs index 760c5d7..86a6253 100644 --- a/scripts/screenshots.mjs +++ b/scripts/screenshots.mjs @@ -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");