Ein Passkey ist der dritte Weg in denselben Trichter: finishLogin() prueft die
Antwort und gibt die Kennung zurueck, der Endpunkt legt damit ueber
createSession() dieselbe Sitzung an wie bei Passwort und OIDC. Die
Sitzungsschicht bleibt unangetastet.
Die Trennung zwischen Pruefung und Sitzung ist Absicht: in passkeys.ts steht
nur WebAuthn, das Setzen von Cookie und Sitzungszeile gehoert zur HTTP-Schicht.
Die Challenge liegt in einem signierten, fuenf Minuten gueltigen httpOnly-
Cookie statt in einer Tabelle — das spart Schema und Aufraeumjob fuer alles,
was liegen bleibt, wenn jemand den Dialog abbricht.
Die RP ID kommt aus APP_URL, nicht aus dem Host-Header. Hinter einem Reverse
Proxy hinge sie sonst am falschen Namen, und ein Passkey ist fest an diesen
Namen gebunden. Ueber nacktes HTTP verschwindet die Verwaltung mit einem
Hinweis statt einen kryptischen Browserfehler zu zeigen.
Den letzten verbleibenden Anmeldeweg laesst sich nicht entfernen: wer kein
Passwort und keine OIDC-Verknuepfung hat, behaelt seinen letzten Passkey.
Die Bibliothek prueft den Signaturzaehler selbst und wirft dabei gewoehnliche
Error. Die werden jetzt uebersetzt, damit eine erwartete Ablehnung nicht als
unerwarteter Serverfehler im Log landet. Die eigene Zaehlerpruefung bleibt als
zusaetzliche Sicherung fuer den Fall, den die Bibliothek durchlaesst.
Geprueft mit virtuellem Authenticator gegen einen Produktionsbau: anlegen,
abmelden, Anmeldung allein per Passkey, Sitzungscookie gesetzt,
Challenge-Cookie verbraucht, Passwortweg unveraendert. Mit kuenstlich
hochgesetztem Zaehler wird die Anmeldung abgelehnt und der Zaehler bleibt
stehen. Dazu sieben Tests fuer Verwaltung, Rechte und die Aussperrsperre.
Refs #6
War ein Film einmal als gesehen markiert, gab es keinen Weg zurueck: im
Zustand "gesehen" stand statt eines Knopfes ein <p> — ein Etikett ohne
Klickziel. Der Statuswechsel konnte das nicht heilen, weil "gesehen" aus den
Sichtungen abgeleitet wird und setStatus() die absichtlich nicht anfasst.
Und eine Gegenfunktion fuer Filme gab es nicht: unmarkEpisodes() und
unmarkSeason() waren da, ein unmarkMovie() nie geschrieben.
unmarkMovie() ist auf run: entry.currentRun und episodeId: null eingegrenzt.
Wer einen Film dreimal gesehen hat, hat drei Sichtungen — ohne die
Eingrenzung loeschte das Zuruecknehmen alle drei und verfaelschte
rueckwirkend die Statistik.
Die gruene Plakette bleibt stehen, ist jetzt aber klickbar, so wie sich
Episodenhaekchen auch abwaehlen lassen. Ueber die Schnittstelle geht es mit
DELETE {"action":"movie"} als Gegenstueck zum gleichnamigen POST, ohne dass
man die watchIds kennen muss.
Drei Tests: Zuruecknehmen samt erneutem Abhaken, die Eingrenzung auf den
laufenden Durchlauf, und dass Serien abgewiesen werden. Klickweg und
Endpunkt gegen einen Produktionsbau geprueft.
Der im Issue vermutete Nebenpunkt trifft nicht zu: report() blendet das
Zuruecknehmen nur bei ids.length > 0 ein, ein wirkungsloses Angebot gab es
also nie.
Refs #5
Ein Universum kann mehrere Reihenfolgen haben, aber welche beim Oeffnen
erschien, liess sich nicht bestimmen — es war immer die
Erscheinungsreihenfolge. Das Feld isDefault gab es schon und wurde beim
Oeffnen auch ausgewertet; es fehlte nur der Weg, es zu setzen.
Der Standard gehoert dem Universum, nicht der einzelnen Person: wer
Schreibrecht hat, legt ihn fuer alle fest — so wie die Reihenfolgen selbst
auch geteilt sind. Die Rangfolge bleibt dadurch die schlichte: die Adresse
schlaegt den Standard schlaegt die erste.
setDefaultOrder() setzt zurueck und neu in einer Transaktion, damit nie zwei
Standards nebeneinander stehen. deleteOrder() faengt jetzt den Fall ab, dass
die Standardreihenfolge geloescht wird — dann uebernimmt die
Erscheinungsreihenfolge, statt dass die Auswahl auf "die erste" faellt.
Ueber die Schnittstelle geht es per PATCH /api/v1/universen/{id}. Der
Endpunkt prueft, dass die Reihenfolge zu genau diesem Universum gehoert:
ohne das setzte ein PATCH auf A den Standard in B, weil die Domaenenschicht
das Universum aus der Reihenfolge ableitet.
Vier Tests dazu: Umstellen samt Eindeutigkeit, Vorrang der Adresse,
Rueckfall beim Loeschen, Rechtepruefung. Klickweg und Endpunkt gegen einen
Produktionsbau geprueft.
Refs #4
Streaming-Verfügbarkeit beantwortet nur die halbe Frage: manche Filme laufen
nirgends mehr, die hat man gekauft und liegen zu Hause — in Jellyfin, Plex,
Emby, auf dem NAS oder als Scheibe.
Bewusst getrennt vom Feld „wo schaue ich das": das eine sagt, wo ein Titel
gerade läuft, und das ändert sich ständig; das andere sagt, was einem gehört,
und das bleibt. Auf der Detailseite steht die Sammlung deshalb vor der
Verfügbarkeit — was man hat, ist die verlässlichere Antwort.
Durchgezogen bis in Filter, Export, CSV-Import und REST-API.
Dieselben Endpunkte bedienen Skripte und das eigene Frontend: erst wird ein
Bearer-Token geprüft, sonst das Sitzungscookie.
Sichtungsaktionen geben immer watchIds zurück — damit lässt sich genau diese
Aktion über DELETE zurücknehmen, ohne raten zu müssen, was dazugehörte.
Die OpenAPI-Beschreibung ist handgeschrieben statt aus Zod erzeugt: die
Schnittstelle ist überschaubar, und ein Generator brächte eine weitere
Abhängigkeit für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
Frischt laufende Serien auf, meldet neue Episoden und neu verfügbare
Watchlist-Titel, räumt Papierkorb und abgelaufene Sitzungen.
Grundsatz: Der Lauf darf nie an einem einzelnen Titel scheitern. Fehler werden
gezählt und protokolliert, der Lauf macht weiter.
Neue Episoden werden nur gemeldet, wenn sie zeitlich nicht in ferner
Vergangenheit liegen — sonst löste der erste Abgleich einer alten Serie hundert
Meldungen aus.
Der Zeitplan ist ein einfacher Timer statt einer Cron-Bibliothek: Flimmer
braucht genau einen täglichen Lauf. Wer mehr will, stösst /api/cron/sync von
aussen an.
Der Katalog ist bewusst gemeinsam für alle Nutzer und über (Typ, TMDB-ID)
dedupliziert: zehn Leute mit derselben Serie brauchen die Episodenliste nur
einmal. Persönliches steckt ausschließlich in LibraryEntry und Watch.
Poster laufen über /api/image statt direkt zu TMDB — einmal holen, danach
vom eigenen Server ausliefern. Größe und Pfad werden streng geprüft, damit
daraus kein offener Proxy wird.
Staffel 0 (Specials) wird importiert, zählt aber nicht zum Fortschritt;
Episoden ohne eigene Laufzeit fallen auf die durchschnittliche Episodenlänge
der Serie zurück.
/api/health prüft die Datenbankverbindung mit und antwortet bei Ausfall mit
503 — eine App ohne Datenbank ist nicht gesund, auch wenn der Prozess läuft.
Passwörter mit bcrypt, Sitzungen als opake Token in der Datenbank — im
Cookie steht das Klartext-Token, in der Tabelle nur dessen SHA-256. Damit
lassen sich einzelne Geräte gezielt abmelden, was reine JWT-Sitzungen nicht
hergeben.
Der erste registrierte Zugang wird automatisch Administrator, sonst käme
man nach dem Aufsetzen nicht in die Verwaltung. Die Registrierung ist
instanzweit abschaltbar; solange es keinen Nutzer gibt, bleibt sie offen.
Enthält außerdem API-Schlüssel je Nutzer als Grundlage für die spätere
REST-API und die Instanz-Einstellungen in der Tabelle AppSetting.