Release 0.8.0 #14
@@ -7,6 +7,121 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [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
|
## [0.7.1] — 2026-09-01
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -112,6 +112,13 @@ 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.
|
||||||
|
|
||||||
|
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
|
||||||
@@ -122,16 +129,25 @@ 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.
|
||||||
- **Klickwege niemals gegen `npm run dev` prüfen.** Unter headless Chromium
|
- **Wenn Klicks unter Playwright wirkungslos bleiben, liegt es meist nicht am
|
||||||
scheitert der HMR-Socket (`ERR_INVALID_HTTP_RESPONSE`), dadurch hydratisiert
|
eigenen Code.** Beobachtet: gegen `npm run dev` reagierte unter headless
|
||||||
**keine** Seite und kein Knopf reagiert — ohne Seitenfehler, ohne
|
Chromium **keine** Seite — kein Knopf, nicht einmal der Theme-Umschalter,
|
||||||
fehlgeschlagene Anfrage, es passiert einfach nichts. Sieht aus wie ein Fehler
|
ohne Seitenfehler und ohne fehlgeschlagene Anfrage. Begleitet vom
|
||||||
im eigenen Code, ist aber die Umgebung. Stattdessen
|
scheiternden HMR-Socket (`ERR_INVALID_HTTP_RESPONSE`).
|
||||||
`npm run build` und `PORT=3012 npm start`, dann dagegen klicken.
|
**Der Zustand ist aber nicht dauerhaft:** in einem späteren Lauf gegen
|
||||||
Formulare täuschen zusätzlich: die Anmeldung funktioniert auch ohne
|
denselben Dev-Server funktionierten dieselben Klicks. Der HMR-Fehler tritt
|
||||||
JavaScript, wirkt also intakt.
|
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 —
|
- 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.
|
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
|
- Testdaten nach Klickproben wieder herstellen: die Proben schreiben in die
|
||||||
geseedete Entwicklungsdatenbank.
|
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
|
## Gesamtübersicht
|
||||||
|
|
||||||
| Etappe | Version | Dauer |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
**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
|
### 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
|
## Benachrichtigungen
|
||||||
|
|
||||||

|

|
||||||
@@ -301,6 +335,8 @@ Tabellenkalkulationen.
|
|||||||
|
|
||||||
## Jellyfin anbinden
|
## Jellyfin anbinden
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
Wer seine Mediathek selbst hostet, pflegt Bestand und Gesehen-Status meist
|
Wer seine Mediathek selbst hostet, pflegt Bestand und Gesehen-Status meist
|
||||||
schon in Jellyfin. Flimmer kann beides übernehmen, damit du es nicht doppelt
|
schon in Jellyfin. Flimmer kann beides übernehmen, damit du es nicht doppelt
|
||||||
führst.
|
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",
|
"name": "flimmer",
|
||||||
"version": "0.7.1",
|
"version": "0.8.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",
|
||||||
|
|||||||
@@ -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 { 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,68 @@ 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);
|
||||||
|
|
||||||
|
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 +141,64 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Hell ---
|
// --- Hell ---
|
||||||
{
|
{
|
||||||
const { ctx, page } = await session(1400, 900, "light");
|
const { ctx, page } = await session(1400, 900, "light");
|
||||||
|
|||||||