23 Commits
Author SHA1 Message Date
florian.wilde 57151a770e Merge #19: Version 0.9.0
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s
2026-09-02 12:47:52 +00:00
Florian Wilde 09625c89ef Version 0.9.0
CI / pruefen (pull_request) Canceled after 0s
Errungenschaften und die Auswahlliste fuer unbekannte Jellyfin-Titel.
Changelog, Release-Notiz, DEVLOG und Projektgedaechtnis nachgezogen.

Die Anleitung hat ihre beiden Abschnitte schon mit den jeweiligen Issues
bekommen — Errungenschaften mit Screenshot, „Titel, die Flimmer noch nicht
kennt" im Jellyfin-Kapitel.
2026-09-02 14:47:23 +02:00
florian.wilde f8d5566865 Merge #18: Unbekannte Jellyfin-Titel zur Auswahl stellen und nachladen
CI / pruefen (push) Canceled after 0s
Closes #16
2026-09-02 11:27:26 +00:00
florian.wilde 6847d08011 Merge #17: Errungenschaften — Abzeichen fuer erreichte Meilensteine
CI / pruefen (push) Canceled after 0s
Closes #15
2026-09-02 11:27:11 +00:00
Florian Wilde 7f890155ae Unbekannte Jellyfin-Titel zur Auswahl stellen und nachladen
CI / pruefen (pull_request) Canceled after 0s
Der Abgleich ordnete nur gegen Titel zu, die Flimmer schon kannte. Auf einer
frisch aufgesetzten Instanz meldet Jellyfin damit tausend Eintraege und
Flimmer ordnet keinen einzigen zu — der Bericht konnte nur aufzaehlen, was er
nicht einsortieren konnte.

Aus dem Bericht wird eine Auswahlliste: anhaken, was uebernommen werden soll,
und nur das aus TMDB nachladen. Ein pauschales Haekchen waere bei tausend
Eintraegen keine Wahl gewesen — in einer Mediathek liegen Konzertmitschnitte
neben dem, was man wirklich verfolgt.

Der Bericht ist dafuer umgebaut:

- Zusammengefasst je TMDB-Kennung. Jellyfin fuehrt oft mehrere Dateien zum
  selben Film; in der Liste steht er einmal, mit der Zahl der Dateien.
- Die Grenze stand bei 50 und war bei ueber tausend gelesenen Eintraegen ein
  Blick durchs Schluesselloch. Jetzt 1000, und was darueber hinausgeht, wird
  gezaehlt statt stillschweigend abgeschnitten.
- Der Typ kommt aus dem Item statt aus dem Umkehrschluss "keine Episode, also
  Film". Eine Probe zeigte sonst eine Serie als Film.

Nachgeladen wird nur auf Klick, hoechstens hundert je Lauf, und ein einzelner
Fehlschlag beendet den Lauf nicht. Aus der Liste verschwindet nur, was
tatsaechlich ankam: ein Fehlschlag laesst den Eintrag stehen, damit man es
erneut versuchen kann, ohne den ganzen Abgleich zu wiederholen. Mein erster
Entwurf strich auch Gescheiterte — der Test hielt das falsche Verhalten fest
und wurde mitkorrigiert.

Eintraege ohne TMDB-Kennung bleiben sichtbar, aber nicht auswaehlbar: dort
fehlt die Kennung in Jellyfin selbst, da kann Flimmer nichts holen.

Fuenf Tests dazu. Der Importschritt selbst laesst sich lokal nur ueber die
Domaenenschicht pruefen — der Entwicklungs-Stub speist sich aus dem Katalog
und kann per Definition nichts liefern, was dort fehlt. Der Klickweg belegt
Liste, Entdopplung, Suche, Auswahl, die gesperrten Zeilen und die ehrliche
Fehlermeldung.

Refs #16
2026-09-02 13:12:19 +02:00
Florian Wilde b560ee9c51 Errungenschaften: Abzeichen fuer erreichte Meilensteine
CI / pruefen (pull_request) Canceled after 0s
Zwanzig feste Abzeichen plus dynamische fuer vollstaendig gesehene Filmreihen
und abgeschlossene Universen. Sie tauchen auf, wenn etwas erreicht ist, und
bleiben — auch wenn die Bedingung spaeter wegfaellt. Eine zurueckgenommene
Sichtung soll kein Abzeichen kosten.

Alle Regeln arbeiten auf *einem* Datensatz, der einmal geholt wird. Sonst
stellte jede Regel ihre eigene Abfrage, und aus zwanzig Abzeichen wuerden
zwanzig Abfragen bei jedem Abhaken. Ausgewertet wird nach Sichtungsaktionen
und einmal taeglich; der erste Lauf eines Kontos ist stumm, weil eine
gewachsene Bibliothek sonst zwei Dutzend Meldungen auf einmal ausloest.

Fuer Filmreihen braucht es keine gepflegte Liste: TMDB gruppiert sie selbst,
und tmdbCollectionId liegt beim Import schon am Titel. Eigennamen wie
"Spider Bro" kommen aus einer kleinen Datendatei obendrauf; fehlt ein Eintrag,
greift der generische Name.

Die kuratierten Eintraege tragen jeweils ein Stueck des erwarteten
TMDB-Namens. Ich habe die Collection-Kennungen aus dem Gedaechtnis
geschrieben und kann sie hier nicht gegen TMDB pruefen — passt der Name
nicht, wird der Eintrag ignoriert und der generische greift. Eine falsch
abgeschriebene Kennung bleibt damit folgenlos statt jemandem das falsche
Abzeichen zu geben.

Die Uhrzeit-Regel liest die Stunde ueber formatToParts statt ueber die
formatierte Zeichenkette: auf Deutsch liefert Intl fuer eine einzelne Stunde
"03 Uhr", und daraus wird beim Umwandeln NaN. Ein Test hat das aufgedeckt.

Beim ersten Oeffnen der Uebersicht wird einmal ausgewertet — sonst bliebe sie
bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt.

Zehn Tests, Anleitung mit Screenshot. Symbole als eigene SVG auf einem
gemeinsamen Raster; erreicht und offen unterscheiden sich nicht nur durch
Farbe, sondern auch im Text.

Refs #15
2026-09-02 12:09:51 +02:00
florian.wilde 3671fbdc40 Merge #14: Version 0.8.0
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s
2026-09-02 08:36:27 +00:00
Florian Wilde 0f9ad6e29d Version 0.8.0
CI / pruefen (pull_request) Canceled after 0s
Passkeys und die Jellyfin-Anbindung als groessere Bausteine, dazu vier
Nacharbeiten aus dem Betrieb. Changelog, Release-Notiz, DEVLOG und die
bebilderte Anleitung nachgezogen.

Die Anleitung hat zwei neue Abschnitte samt Screenshots: „Anmelden per
Passkey" und ein Bild im Jellyfin-Abschnitt. Beide Aufnahmen brauchen
Gegenstellen — einen Authenticator und einen Medienserver. Damit
`npm run screenshots` reproduzierbar bleibt, stellt das Skript sie jetzt
selbst bereit: der virtuelle Authenticator kommt ueber CDP vom Browser, der
Jellyfin-Nachbau sind ein paar Zeilen HTTP im Skript. Beide Spuren raeumt es
nach der Aufnahme ueber die Oberflaeche wieder weg.

Der Merkposten zum Entwicklungsmodus in CLAUDE.md war zu absolut: dass unter
Playwright nichts hydratisiert, stimmt nicht durchgehend — im
Screenshot-Lauf gegen denselben Dev-Server funktionierten die Klicks. Der
Eintrag beschreibt jetzt das Beobachtete statt einer Regel, die nicht traegt,
und nennt die Pruefung, mit der sich der Zustand feststellen laesst. Dazu neu:
APP_URL muss zum aufgerufenen Port passen, sonst scheitern Passkeys am
Ursprungsabgleich.

Alle Screenshots neu aufgenommen — die Einstellungsseite hatte sich durch die
neuen Abschnitte ohnehin geaendert.
2026-09-02 10:35:43 +02:00
florian.wilde 2deb7e1aa5 Merge #13: Jellyfin anbinden — Bestand und Sichtungen uebernehmen
CI / pruefen (push) Canceled after 0s
Closes #1
2026-09-02 08:20:00 +00:00
Florian Wilde dd2b7dd48b Jellyfin anbinden: Bestand und Sichtungen uebernehmen
CI / pruefen (pull_request) Canceled after 0s
Zugeordnet wird ueber die TMDB-Kennung, die Jellyfin zu jedem Item
mitliefert — dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut. Eine
unscharfe Suche ueber Titel und Jahr ist damit nicht noetig: die Zuordnung ist
eindeutig oder sie findet nicht statt. Was Jellyfin ohne Kennung fuehrt,
landet im Bericht statt falsch zugeordnet zu werden.

Angemeldet wird mit Benutzername und Passwort, nicht mit einem API-Schluessel.
Mit einem Schluessel liefert Jellyfin den Bestand ohne UserData, also ohne
Gesehen-Status — und zwar ohne Fehlermeldung. Der Verbindungstest prueft
deshalb ausdruecklich nach und sagt es, statt den Abgleich stumm ins Leere
laufen zu lassen. Das Zugangstoken liegt mit AES-256-GCM verschluesselt in der
Datenbank; abgeleitet aus AUTH_SECRET.

Der Abgleich ist eine Einbahnstrasse: er ergaenzt, was auf dem Server liegt
oder dort gelaufen ist, und loescht nie etwas, das in Flimmer steht. Fehlende
Titel werden nur aufgenommen, wenn das Haekchen gesetzt ist — im taeglichen
Lauf also nie.

Entdoppelt wird zweifach: Episoden ueber (userId, episodeId, run), Filme
ausdruecklich per Nachschlagen. Bei Filmen ist episodeId null, und Postgres
behandelt NULL im eindeutigen Index als jeweils verschieden — der Index greift
dort nicht.

Watch.source haelt fest, was von wo kam. Ohne diese Angabe liesse sich spaeter
nicht mehr sagen, welche Sichtung aus Jellyfin stammt.

Umgesetzt sind Stufe 1 und 2. Stufe 3 (Zurueckschreiben) kommt in der
Abnahmeliste nicht vor und braeuchte eine Versionsweiche, die sich ohne echten
Server nicht verlaesslich pruefen laesst; das Feld writeBack ist angelegt, die
Logik dahinter bleibt leer.

Elf Tests gegen nachgebildete Antworten. Zusaetzlich der ganze Klickweg gegen
einen echten HTTP-Server im Produktionsbau: verbinden, pruefen, abgleichen,
zweiter Lauf ohne Dubletten, und der Warnfall ohne UserData.

Refs #1
2026-09-02 09:24:38 +02:00
florian.wilde 824cc7764c Merge #12: Anmeldung per Passkey (WebAuthn)
CI / pruefen (push) Canceled after 0s
Closes #6
2026-09-02 06:59:27 +00:00
Florian Wilde c0579c6f61 Anmeldung per Passkey (WebAuthn)
CI / pruefen (pull_request) Canceled after 0s
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
2026-09-02 07:04:45 +02:00
florian.wilde 58a1d177df Merge #11: Rueckmeldung beim Tippen in beiden Suchen
CI / pruefen (push) Canceled after 0s
Closes #3
2026-09-02 04:33:28 +00:00
Florian Wilde d04248bec9 Rueckmeldung beim Tippen in beiden Suchen
CI / pruefen (pull_request) Canceled after 0s
Die Bibliotheksfilterleiste erzeugte das Signal laengst und warf es weg:
`const [, startTransition] = useTransition()`. Waehrend der Suche blieben die
alten Treffer unveraendert stehen — ohne jeden Hinweis, dass gearbeitet wird.

Beide Suchen teilen sich jetzt eine schmale Klammer (SearchPending): die
Filterleiste meldet den Ladezustand, die Trefferliste daneben — eine
Server-Komponente — wird gedaempft und auf aria-busy gesetzt. Die alten
Treffer bleiben sichtbar; sie verschwinden zu lassen liesse die Liste bei
jedem Tastendruck springen. Der Hinweis „sucht …" steht jetzt auch in der
Bibliothek, nicht nur unter /suche.

Sammelverzoegerung in beiden auf 250 ms angeglichen (vorher 350 und 300).

Zwei Vermutungen aus dem Issue haben sich beim Nachmessen NICHT bestaetigt
und bleiben deshalb unangetastet:

Die Facetten kosten nichts. Gemessen an einer eigens angelegten Bibliothek
mit 2000 Eintraegen (Median aus 9 Laeufen): getLibrary 50 ms,
getLibraryFacets 17 ms, beide parallel 61 ms. Ein Wegfall spaerte rund 12 ms
— eine Zwischenspeicherung braechte dafuer das Risiko veralteter Zaehler.

Langsame Bilder blockieren die Trefferanzeige nicht. Mit kuenstlich um
1500 ms verzoegerten /api/image-Antworten stehen die Treffer nach 465 ms im
DOM, 891 ms vor den Bildern; die Rueckfallkacheln zeigen derweil die Titel.
Ein Umbau des Bild-Endpunkts waere also Aufwand ohne Wirkung.

Refs #3
2026-09-02 00:23:04 +02:00
florian.wilde 605afe556d Merge #10: Update-Skript erkennt den Container-Zustand, Merkposten ergaenzt
CI / pruefen (push) Canceled after 0s
2026-09-01 22:13:49 +00:00
Florian Wilde 39098cd201 chore: Update-Skript erkennt Container-Zustand, Merkposten ergaenzt
CI / pruefen (pull_request) Canceled after 0s
Das Update-Skript prüfte allein http://127.0.0.1:APP_PORT/api/health. Hinter
einem Reverse Proxy ist der App-Port oft gar nicht auf dem Host
veröffentlicht — dann meldete das Skript nach zwei Minuten einen Fehlschlag,
obwohl das Update sauber durchgelaufen war.

Schlägt die Abfrage vom Host fehl, zählt jetzt der HEALTHCHECK des Containers.
Den bringt das Image schon mit; er fragt /api/health von innen ab und ist
damit unabhängig von der Portveröffentlichung.

Im Fehlerfall nennt das Skript zusätzlich den Container-Zustand und ordnet ihn
ein: kein Container (Start fehlgeschlagen), startet noch (Migrationen dauern),
läuft aber ungesund (Fehler in der Anwendung), oder ein älteres Image ohne
Healthcheck. Ohne diese Auskunft sah ein nicht veröffentlichter Port genauso
aus wie eine abgestürzte Anwendung.

container_zustand() gibt bewusst immer 0 zurück — unter set -e dürfte die
Diagnose nicht selbst abbrechen, bevor der Weg zurück ausgegeben ist.

Dazu drei Merkposten in CLAUDE.md, die diese Sitzung Zeit gekostet haben:
Klickwege müssen gegen einen Produktionsbau geprüft werden (im
Entwicklungsmodus hydratisiert unter headless Chromium nichts, weil der
HMR-Socket scheitert — ohne jede Fehlermeldung), vor npm start den Port
prüfen, und dass scripts/shot.mjs sich nicht anmeldet.
2026-09-02 00:11:22 +02:00
florian.wilde fd1ab2918a Merge #9: Filmsichtung laesst sich wieder zuruecknehmen
CI / pruefen (push) Canceled after 0s
Closes #5
2026-09-01 22:06:54 +00:00
Florian Wilde 40fb49fb21 Filmsichtung laesst sich wieder zuruecknehmen
CI / pruefen (pull_request) Canceled after 0s
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
2026-09-02 00:04:27 +02:00
florian.wilde 5673055216 Merge #8: Standardreihenfolge je Universum festlegen
CI / pruefen (push) Canceled after 0s
Closes #4
2026-09-01 21:53:34 +00:00
Florian Wilde 853d56f764 Standardreihenfolge je Universum festlegen
CI / pruefen (pull_request) Canceled after 0s
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
2026-09-01 23:49:52 +02:00
florian.wilde 9d4e419cd1 Merge #7: Titelraster auf eine feste Zeilenhoehe bringen
CI / pruefen (push) Canceled after 0s
Closes #2
2026-09-01 21:31:43 +00:00
Florian Wilde a853b066b5 Titelraster auf eine feste Zeilenhoehe bringen
CI / pruefen (pull_request) Canceled after 0s
Der Titelblock hatte mit line-clamp-2 zwar eine Ober-, aber keine
Untergrenze. Einzeilige Titel belegten dadurch 19 statt 39 Pixel, und alles
darunter rutschte um eine Zeilenhoehe nach oben — neben Karten mit
zweizeiligem Titel wirkte das Raster ausgefranst.

Der Titelblock bekommt jetzt mit min-h-[2lh] immer zwei Zeilen. Die Einheit
lh haengt an der tatsaechlichen Zeilenhoehe, damit der Wert nicht von einer
Aenderung an leading-snug abgehaengt wird.

In der Bibliothekskarte kam hinzu, dass der Fortschrittsbalken nur bei
Serien erscheint. Sein Platz wird jetzt auch dann freigehalten, wenn er
leer bleibt — als unsichtbarer Nachbau statt einer festen Pixelhoehe, damit
er sich von selbst nachzieht.

Nachgemessen bei 1400 px: Metazeilen einer Reihe liegen jetzt alle auf
derselben Oberkante (Suche 711 px, Bibliothek 715/1117/1520 px), und alle
Karten sind gleich hoch (353 bzw. 387 px). Vorher: 691 gegen 711 px und
Kartenhoehen von 333 bis 367 px. Bei Handybreite ebenfalls sauber.

Refs #2
2026-09-01 23:29:12 +02:00
Florian Wilde 4ad5b244bc fix: eigener Projektname fuer die Entwicklungsdatenbank
CI / pruefen (push) Canceled after 0s
Beide Compose-Dateien leiteten den Projektnamen aus dem Verzeichnis ab. Ein
'docker compose down -v' auf dem Produktivstack nahm dadurch das Volume der
Entwicklungsdatenbank mit — genau das ist beim Testen passiert.
2026-09-01 22:57:22 +02:00
83 changed files with 6669 additions and 128 deletions
+7
View File
@@ -71,6 +71,13 @@ SMTP_PASSWORD=
SMTP_SECURE=false
SMTP_FROM=Flimmer <flimmer@example.com>
# --- Passkeys (WebAuthn) -----------------------------------------------------
# Anmeldung per Fingerabdruck, Gesicht oder Sicherheitsschlüssel. Standardmäßig an.
# Passkeys brauchen HTTPS — Ausnahme ist localhost. Sie sind fest an den
# Hostnamen aus APP_URL gebunden: nach einem Domainwechsel müssen alle
# Passkeys neu angelegt werden.
ENABLE_PASSKEYS=true
# --- OIDC / SSO (optional) ---------------------------------------------------
# Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert.
# Redirect-URI beim Anbieter eintragen: <APP_URL>/api/auth/oidc/callback
+208
View File
@@ -7,6 +7,214 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
## [0.9.0] — 2026-09-02
**Errungenschaften** — Abzeichen für erreichte Meilensteine. Dazu die
**Auswahlliste** beim Jellyfin-Abgleich, ohne die die Anbindung auf einer
gewachsenen Mediathek kaum etwas ausrichtete.
> ⚠️ **Beim Aktualisieren kommt eine Migration mit** (`errungenschaften`). Der
> Container spielt sie beim Start selbst ein; `./update.sh` legt vorher eine
> Sicherung an.
### Hinzugefügt
- **Errungenschaften.** Zwanzig feste Abzeichen, dazu dynamische für
vollständig gesehene Filmreihen und abgeschlossene Universen. Zu finden
unten auf der Statistikseite.
Sie tauchen auf, wenn etwas erreicht ist, und bleiben dann — **keine
Streaks**, die reißen, wenn man eine Woche nichts schaut, keine
Fortschrittsbalken, die drängeln. Wer eine Sichtung zurücknimmt, verliert
kein Abzeichen; es wieder einzukassieren wäre kleinlich.
Ein paar davon: *Der lange Atem* (eine Serie mit hundert Episoden komplett),
*Treu geblieben* (über ein Jahr an demselben Titel), *Durchgemacht*
(zwischen drei und fünf Uhr morgens abgehakt), *Zeitreisender* (Titel aus
sieben Jahrzehnten), *Strenges Urteil* (einmal die Note 1 vergeben).
Für **ganze Filmreihen** braucht es keine gepflegte Liste: TMDB gruppiert
sie selbst, und die Zugehörigkeit steht beim Import schon am Titel. Wer alle
Spider-Man-Filme gesehen hat, bekommt sie als Reihe angerechnet. Für
bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht „Reihe
komplett — …".
Beim ersten Öffnen wird die ganze Historie einmal **stumm** ausgewertet —
eine gewachsene Bibliothek soll nicht zwei Dutzend Meldungen auf einmal
auslösen. Erst danach wird gemeldet, was neu dazukommt. Auf dem öffentlichen
Profil lassen sich die Abzeichen zeigen oder ausblenden.
- **Auswahlliste für Jellyfin-Titel, die Flimmer nicht kennt.** Beim ersten
Abgleich einer gewachsenen Mediathek passierte bisher etwas Ernüchterndes:
Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen einzigen** zu,
weil der Katalog sie nicht kennt.
Jetzt steht unter dem Bericht eine Liste zum Anhaken. Was ausgewählt ist,
holt Flimmer von TMDB — bei Serien samt Staffeln und Episoden — und nimmt es
mit „hab ich · Jellyfin" in die Bibliothek auf.
Die Liste fasst zusammen: Führt Jellyfin drei Dateien zum selben Film
(Extended Edition, zweite Fassung), steht er einmal da, mit dem Vermerk, wie
viele Dateien darauf zeigen. Einträge, denen in Jellyfin die TMDB-Kennung
fehlt, bleiben sichtbar, sind aber nicht auswählbar — dort kann Flimmer
nichts holen.
Geholt werden hundert je Lauf; der Rest bleibt für den nächsten Klick. Ein
einzelner Fehlschlag beendet den Lauf nicht, und **aus der Liste verschwindet
nur, was tatsächlich ankam**.
### Geändert
- Der Bericht des Medienserver-Abgleichs war auf **50 Einträge** gekürzt —
bei über tausend gelesenen ein Blick durchs Schlüsselloch. Jetzt bis 1000,
und was darüber hinausgeht, wird gezählt statt stillschweigend
abgeschnitten.
- Das Häkchen beim Abgleich heißt jetzt „**Bekannte** Titel dabei in die
Bibliothek aufnehmen". Es nimmt auf, was Flimmer *kennt*; die neue
Auswahlliste holt, was es *nicht kennt*. Der Unterschied stand vorher
nirgends — und war der Grund, warum das Häkchen bei einer frischen Instanz
nichts bewirkte.
- Einblendungen kennen einen dritten Ton für Erfolge.
- `npm run screenshots` hebt für Ausschnittaufnahmen die klebende Kopfleiste
auf; sie legte sich sonst über die Überschrift.
### Behoben
- Im Bericht stand eine Serie als **Film**, weil der Typ aus dem Umkehrschluss
„keine Episode, also Film" abgeleitet wurde. Er kommt jetzt aus dem Item.
### Getestet
93 Tests (vorher 78). Neu: der Regelkatalog der Errungenschaften samt
Stummschaltung, Entdopplung und Zeitzonenrechnung; die Auswahlliste samt
Zusammenfassung, Fehlschlägen und Rechteprüfung.
Ein Test hat dabei einen Fehler gefunden, den man leicht übersieht: Die Regel
„zwischen drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette,
und auf Deutsch liefert das System dafür **„03 Uhr"** — als Zahl gelesen wird
daraus nichts. Das Abzeichen wäre nie vergeben worden.
### Anleitung
`docs/ANLEITUNG.md` hat zwei neue Abschnitte: **Errungenschaften** mit
Screenshot und **Titel, die Flimmer noch nicht kennt** im Jellyfin-Kapitel.
## [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
+33
View File
@@ -96,6 +96,9 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`.
Achtung: das Skript meldet sich **nicht** an — bei geschützten Seiten entsteht
kommentarlos ein Bild der Anmeldemaske. Für angemeldete Ansichten den
Login-Weg aus `scripts/screenshots.mjs` nachbauen.
- Next hängt selbst einen Block an diese Datei an — der bleibt drin.
## Fortschritt
@@ -109,6 +112,15 @@ 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.
- ✅ **Zweite Runde** (v0.9.0) — Errungenschaften, Auswahlliste für unbekannte
Jellyfin-Titel. 37 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
@@ -119,6 +131,27 @@ 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.
- **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.
<!-- BEGIN:nextjs-agent-rules -->
+81 -1
View File
@@ -103,6 +103,84 @@ 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.
---
## Zweite Issue-Runde (v0.9.0)
| # | Issue | Dauer | Was passiert ist |
|---|---|---|---|
| 9.1 | #15 Errungenschaften | 22 min | Regelkatalog auf einem gemeinsamen Datensatz; Filmreihen über die TMDB-Zugehörigkeit statt einer gepflegten Liste |
| 9.2 | #16 Titel nachladen | 15 min | Aus dem Bericht wird eine Auswahlliste; was Flimmer nicht kennt, kommt auf Wunsch aus TMDB |
**Issues gesamt: 37 min** (2026-09-02, 09:47 – 11:13)
Dazu ohne Zeiterfassung: zwei Issues angelegt (#15 und #16), diese Version
selbst und die Nachträge an der Anleitung.
### Was dabei auffiel
1. **Ein Test hat eine Formatierungsfalle aufgedeckt.** Die Regel „zwischen
drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette; auf
Deutsch liefert `Intl` dafür „03 Uhr", und `Number("03 Uhr")` ist `NaN`.
Das Abzeichen wäre nie vergeben worden.
2. **Ich hatte ein fragwürdiges Verhalten in einem Test festgeschrieben.**
Der erste Entwurf strich auch *gescheiterte* Titel aus der Auswahlliste,
und der Test hielt das fest. Erst beim Durchklicken fiel auf, wie ärgerlich
das wäre — Code und Test wurden korrigiert.
3. **Ein Klickweg hat eine falsche Beschriftung gezeigt.** Eine Serie stand
als „Film" in der Liste, weil der Typ aus dem Umkehrschluss „keine Episode,
also Film" kam.
4. **Der Entwicklungs-Stub kann nur suchen.** Er speist sich aus dem lokalen
Katalog und kann deshalb per Definition nichts liefern, was dort fehlt —
der Importpfad ließ sich nur über Fixtures belegen, nicht über die
Oberfläche.
5. **Zwei parallele Branches, kein Konflikt.** Beide zweigten von `main` ab
und fassten dieselben drei Dateien an. Der Merge lief glatt; geprüft wurde
trotzdem die Kombination auf `main`, nicht nur die einzelnen Zweige.
---
## Gesamtübersicht
| Etappe | Version | Dauer |
@@ -114,8 +192,10 @@ 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 |
| Zweite Runde — Errungenschaften, Titel nachladen | 0.9.0 | 37 min |
**Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16)
**Gesamtentwicklungszeit: 5 h 01 min** (301 min, 2026-09-01 18:09 – 2026-09-02 11:13)
### Womit die Zeit verging
+25
View File
@@ -178,6 +178,31 @@ mit Erklärungen steht in [`.env.example`](.env.example). Die wichtigsten:
| `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen |
| `SMTP_*` | E-Mail-Versand |
| `OIDC_*` | Optionale SSO-Anbindung (Authelia, Keycloak, Authentik …) |
| `ENABLE_PASSKEYS` | Anmeldung per Passkey (Standard `true`) |
### Passkeys
Statt Passwort geht auch ein Passkey — Windows Hello, Face ID, Touch ID oder ein
Sicherheitsschlüssel. Anlegen unter **Einstellungen → Passkeys**, anmelden über
„Mit Passkey anmelden" ohne vorherige E-Mail-Eingabe. Das Passwort bleibt
daneben bestehen; den letzten verbleibenden Anmeldeweg lässt Flimmer nicht
entfernen.
Drei Dinge, die beim Selbsthosten zählen:
- **HTTPS ist Pflicht.** Der Browser gibt Passkeys nur über eine verschlüsselte
Verbindung frei; einzige Ausnahme ist `localhost`. Läuft deine Instanz über
einfaches HTTP, verschwindet die Passkey-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 muss `APP_URL` also stimmen, sonst hängen die Passkeys am
falschen Namen.
- **Ein Domainwechsel entwertet alle Passkeys.** Wer von `flimmer.fritz.box` auf
`flimmer.example.com` umzieht, muss sie neu anlegen — sie sind fest an den
Hostnamen gebunden. Halte vorher einen zweiten Anmeldeweg bereit.
Ein Passkey, der nur auf einem Gerät liegt (nicht synchronisiert), ist mit dem
Gerät verloren. Die Übersicht in den Einstellungen sagt dazu, was Sache ist.
## Entwicklung
+6
View File
@@ -1,5 +1,11 @@
# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`.
# docker compose -f docker-compose.dev.yml up -d
#
# Eigener Projektname: sonst teilt sich dieser Stack den Namen mit der
# Produktiv-Compose (beide leiten ihn sonst aus dem Verzeichnis ab), und ein
# `docker compose down -v` dort nimmt die Entwicklungsdatenbank mit.
name: flimmer-dev
services:
db:
image: postgres:17-alpine
+157
View File
@@ -237,6 +237,46 @@ Alles ist aus den Sichtungen gerechnet; Rewatches zählen mit.
---
## Errungenschaften
![Errungenschaften](screenshots/23-errungenschaften.webp)
Unten auf der Statistikseite sammeln sich **Abzeichen**. Sie tauchen auf, wenn
etwas erreicht ist — mehr passiert nicht. Keine Serien, die reißen, wenn du
eine Woche nichts schaust, keine Balken, die dich antreiben.
Ein paar davon:
- **Abgehakt** — die erste Serie vollständig durchgeschaut
- **Der lange Atem** — eine Serie mit mindestens hundert Episoden komplett
- **Treu geblieben** — über ein Jahr an demselben Titel drangeblieben
- **Durchgemacht** — zwischen drei und fünf Uhr morgens etwas abgehakt
- **Zeitreisender** — Titel aus sieben Jahrzehnten gesehen
- **Strenges Urteil** — einmal die Note 1 vergeben
Dazu kommen Abzeichen für **ganze Filmreihen**: Wer alle Spider-Man-Filme
gesehen hat, bekommt sie als solche angerechnet. Flimmer erkennt das an der
TMDB-Kennung, die zu jedem Film mitgeliefert wird — es braucht keine gepflegte
Liste. Für bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht
„Reihe komplett — …". Genauso zählen **eigene Universen**, sobald alle Titel
darin abgeschlossen sind.
> **Einmal erreicht, bleibt erreicht.**
> Nimmst du eine Sichtung zurück oder entfernst einen Titel, verlierst du kein
> Abzeichen. Es wieder einzukassieren wäre kleinlich — und ein Fehlklick soll
> nichts kaputt machen.
Beim ersten Öffnen der Seite schaut Flimmer einmal deine ganze Historie durch.
Wenn du Flimmer schon länger nutzt, sind deshalb sofort mehrere Abzeichen da —
ohne dass du dafür eine Flut von Meldungen bekommst. Erst danach wird gemeldet,
was neu dazukommt.
Wenn du ein öffentliches Profil hast, kannst du die Abzeichen dort zeigen oder
ausblenden — der Schalter steht in den Einstellungen direkt bei der
Profiladresse.
---
## Einstellungen
![Einstellungen](screenshots/11-einstellungen.webp)
@@ -256,6 +296,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)
@@ -299,6 +373,89 @@ 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.
Unter **Einstellungen → Medienserver** trägst du die Adresse deines Servers
ein, dazu Benutzername und Passwort. Nach dem Verbinden sagt Flimmer, wie
viele Einträge es gefunden hat — und ob der **Gesehen-Status mitkommt**.
> **Warum Benutzername und Passwort, nicht ein API-Schlüssel?**
> Mit einem API-Schlüssel liefert Jellyfin zwar den Bestand, aber keinen
> Gesehen-Status — und zwar ohne Fehlermeldung. Der Abgleich liefe dann stumm
> ins Leere. Flimmer meldet sich deshalb wie ein normaler Nutzer an und prüft
> beim Verbinden ausdrücklich nach. Das Zugangstoken wird verschlüsselt
> abgelegt, dein Passwort gar nicht gespeichert.
Zugeordnet wird über die **TMDB-Kennung**, die Jellyfin zu jedem Film und jeder
Serie mitliefert. Das ist dieselbe Kennung, auf der Flimmers Katalog aufbaut —
es gibt also kein Raten über Titel und Jahreszahlen. Was Jellyfin ohne Kennung
führt (eigene Aufnahmen etwa), landet im Bericht statt falsch zugeordnet zu
werden.
Beim Abgleich passiert dreierlei:
- Titel, die auf dem Server liegen, bekommen **„hab ich"** mit der Quelle
Jellyfin.
- Was du dort gesehen hast, wird zur **Sichtung** mit dem Termin aus Jellyfin.
- Fehlende Titel werden **nur aufgenommen, wenn du das Häkchen setzt**. Eine
Mediathek mit achthundert Filmen soll nicht ungefragt deine Bibliothek
werden.
Danach läuft der Abgleich im täglichen Hintergrundlauf mit — dort allerdings
ohne automatisches Aufnehmen.
### Titel, die Flimmer noch nicht kennt
Beim ersten Abgleich einer gewachsenen Mediathek passiert oft etwas
Ernüchterndes: Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen
einzigen** zu. Das ist richtig so — Flimmer kennt nur, was du selbst
aufgenommen oder über die Suche importiert hast.
Deshalb steht unter dem Bericht eine **Auswahlliste**: alles, was auf dem
Server liegt und Flimmer unbekannt ist. Hake an, was übernommen werden soll,
und klicke auf Übernehmen — die Daten holt Flimmer dann von TMDB, bei Serien
samt Staffeln und Episoden.
Zwei Dinge, die dabei helfen:
- **Zusammengefasst.** Führt Jellyfin drei Dateien zum selben Film (Extended
Edition, zweite Fassung), steht er trotzdem nur einmal in der Liste — mit
dem Vermerk, wie viele Dateien darauf zeigen.
- **Es werden hundert auf einmal geholt.** Wer mehr auswählt, bekommt den Rest
beim nächsten Klick. Jeder Titel bedeutet einen Abruf bei TMDB, bei Serien
einen je Staffel.
Manche Einträge lassen sich **nicht** anhaken: dort fehlt die TMDB-Kennung in
Jellyfin selbst. Das trifft oft Extended Editions, Konzertmitschnitte oder
Dateien mit eigenwilliger Benennung. Meist hilft es, in Jellyfin die Erkennung
für diesen Titel nachzuziehen — danach taucht er beim nächsten Abgleich mit
Kennung auf.
> **Der Unterschied zwischen den beiden Häkchen**
> „Bekannte Titel dabei in die Bibliothek aufnehmen" beim Abgleich nimmt auf,
> was Flimmer **kennt**, aber noch nicht in deiner Bibliothek steht.
> Die Auswahlliste holt, was Flimmer **nicht kennt**.
**Der Abgleich ist eine Einbahnstraße.** Er ergänzt, was auf dem Server steht,
und löscht nie etwas, das in Flimmer steht. Markierst du einen Film in Flimmer
als gesehen und löschst ihn in Jellyfin, bleibt deine Sichtung erhalten.
> **Eine Grenze, die von Jellyfin kommt**
> Jellyfin merkt sich, *wie oft* du etwas gesehen hast und *wann zuletzt* —
> aber keine Liste der einzelnen Male. Ein Film, den du dreimal gesehen hast,
> kommt deshalb mit **einem** Termin in Flimmers Statistik an, nicht mit drei.
> Rewatch-Durchläufe, die du in Flimmer selbst anlegst, sind davon nicht
> betroffen. Das lässt sich nicht beheben — die Information gibt es an der
> Quelle nicht.
---
## API-Schlüssel
![API-Schlüssel](screenshots/13-api.webp)
+31 -5
View File
@@ -116,6 +116,19 @@ curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/jso
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
Markierung dieser Episoden im aktuellen Durchlauf.
Für Filme gibt es `{"action":"movie"}` als Gegenstück zum gleichnamigen POST;
dafür braucht man die `watchIds` nicht zu kennen:
```bash
curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"action":"movie"}' \
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen
```
Zurückgenommen wird dabei **nur der laufende Durchlauf**. Wer einen Film dreimal
gesehen hat, verliert also nicht die Sichtungen der früheren Durchläufe — die
zählen weiter in die Statistik.
### Suche
```http
@@ -145,15 +158,28 @@ Liefert `totals`, `monthly`, `providers`, `genres` und `yearReview` auf einmal.
### Universen
```http
GET /api/v1/universen
POST /api/v1/universen
GET /api/v1/universen/{id}?reihenfolge={orderId}
POST /api/v1/universen/{id}
GET /api/v1/universen
POST /api/v1/universen
GET /api/v1/universen/{id}?reihenfolge={orderId}
POST /api/v1/universen/{id}
PATCH /api/v1/universen/{id}
```
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
Erscheinungsreihenfolge.
Standardreihenfolge des Universums — in `orders` trägt sie `isDefault`.
`PATCH` legt fest, welche das ist. Die Reihenfolge muss zu diesem Universum
gehören, und es braucht Schreibrecht:
```bash
curl -X PATCH -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"defaultOrderId": "…"}' \
https://flimmer.example.com/api/v1/universen/$ID
```
Je Universum trägt immer genau eine Reihenfolge den Standard. Wird die
Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge.
### Export
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: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+298 -2
View File
@@ -1,16 +1,18 @@
{
"name": "flimmer",
"version": "0.6.1",
"version": "0.7.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flimmer",
"version": "0.6.1",
"version": "0.7.1",
"license": "MIT",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "7.10.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3",
"bcryptjs": "3.0.3",
"clsx": "2.1.1",
"date-fns": "4.4.0",
@@ -536,6 +538,12 @@
"node": ">=18"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1135,6 +1143,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@next/env": {
"version": "16.3.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz",
@@ -1281,6 +1295,207 @@
"node": ">= 10"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.4.tgz",
"integrity": "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.4.tgz",
"integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"@peculiar/asn1-x509-attr": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.4.tgz",
"integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.4.tgz",
"integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.4.tgz",
"integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.9.4",
"@peculiar/asn1-pkcs8": "^2.9.4",
"@peculiar/asn1-rsa": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.4.tgz",
"integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.4.tgz",
"integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.9.4",
"@peculiar/asn1-pfx": "^2.9.4",
"@peculiar/asn1-pkcs8": "^2.9.4",
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"@peculiar/asn1-x509-attr": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.4.tgz",
"integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz",
"integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.4.tgz",
"integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.4.tgz",
"integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.9.4",
"@peculiar/asn1-x509": "^2.9.4",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@prisma/adapter-pg": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz",
@@ -1645,6 +1860,31 @@
}
}
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "13.3.3",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.3.tgz",
"integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/x509": "^1.14.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2585,6 +2825,20 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -4070,6 +4324,24 @@
],
"license": "MIT"
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz",
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/rc9": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
@@ -4116,6 +4388,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/remeda": {
"version": "2.33.4",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
@@ -4429,6 +4707,24 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flimmer",
"version": "0.7.1",
"version": "0.9.0",
"private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT",
@@ -22,6 +22,8 @@
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "7.10.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3",
"bcryptjs": "3.0.3",
"clsx": "2.1.1",
"date-fns": "4.4.0",
@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "Passkey" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"credentialId" TEXT NOT NULL,
"publicKey" BYTEA NOT NULL,
"counter" BIGINT NOT NULL DEFAULT 0,
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
"deviceType" TEXT NOT NULL DEFAULT 'singleDevice',
"backedUp" BOOLEAN NOT NULL DEFAULT false,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3),
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Passkey_credentialId_key" ON "Passkey"("credentialId");
-- CreateIndex
CREATE INDEX "Passkey_userId_idx" ON "Passkey"("userId");
-- AddForeignKey
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,66 @@
-- CreateEnum
CREATE TYPE "WatchSource" AS ENUM ('FLIMMER', 'JELLYFIN');
-- CreateEnum
CREATE TYPE "MediaServerKind" AS ENUM ('JELLYFIN', 'EMBY');
-- AlterTable
ALTER TABLE "Watch" ADD COLUMN "source" "WatchSource" NOT NULL DEFAULT 'FLIMMER';
-- CreateTable
CREATE TABLE "MediaServerConnection" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"kind" "MediaServerKind" NOT NULL DEFAULT 'JELLYFIN',
"name" TEXT NOT NULL DEFAULT 'Jellyfin',
"baseUrl" TEXT NOT NULL,
"token" TEXT NOT NULL,
"serverUserId" TEXT NOT NULL,
"serverUserName" TEXT NOT NULL DEFAULT '',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"syncOwned" BOOLEAN NOT NULL DEFAULT true,
"syncWatched" BOOLEAN NOT NULL DEFAULT true,
"writeBack" BOOLEAN NOT NULL DEFAULT false,
"lastSyncAt" TIMESTAMP(3),
"lastSyncReport" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MediaServerConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MediaServerItem" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"externalId" TEXT NOT NULL,
"titleId" TEXT,
"episodeId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MediaServerItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MediaServerConnection_userId_idx" ON "MediaServerConnection"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "MediaServerConnection_userId_baseUrl_key" ON "MediaServerConnection"("userId", "baseUrl");
-- CreateIndex
CREATE INDEX "MediaServerItem_connectionId_idx" ON "MediaServerItem"("connectionId");
-- CreateIndex
CREATE UNIQUE INDEX "MediaServerItem_connectionId_externalId_key" ON "MediaServerItem"("connectionId", "externalId");
-- AddForeignKey
ALTER TABLE "MediaServerConnection" ADD CONSTRAINT "MediaServerConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MediaServerItem" ADD CONSTRAINT "MediaServerItem_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "MediaServerConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MediaServerItem" ADD CONSTRAINT "MediaServerItem_titleId_fkey" FOREIGN KEY ("titleId") REFERENCES "Title"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MediaServerItem" ADD CONSTRAINT "MediaServerItem_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "Episode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "showAchievements" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "Achievement" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label" TEXT,
"unlockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Achievement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Achievement_userId_unlockedAt_idx" ON "Achievement"("userId", "unlockedAt");
-- CreateIndex
CREATE UNIQUE INDEX "Achievement_userId_key_key" ON "Achievement"("userId", "key");
-- AddForeignKey
ALTER TABLE "Achievement" ADD CONSTRAINT "Achievement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+141 -1
View File
@@ -42,6 +42,9 @@ model User {
publicProfile Boolean @default(false)
profileSlug String? @unique
/// Errungenschaften auf der öffentlichen Profilseite zeigen
showAchievements Boolean @default(true)
// Benachrichtigungswünsche
notifyNewEpisode Boolean @default(true)
notifyRelease Boolean @default(true)
@@ -53,6 +56,9 @@ model User {
sessions Session[]
apiKeys ApiKey[]
oidcAccounts OidcAccount[]
passkeys Passkey[]
mediaServers MediaServerConnection[]
achievements Achievement[]
entries LibraryEntry[]
watches Watch[]
tags Tag[]
@@ -109,6 +115,37 @@ model OidcAccount {
@@index([userId])
}
/// Ein Passkey (WebAuthn) als dritter Anmeldeweg neben Passwort und OIDC.
model Passkey {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Kennung des Authenticators, base64url — die Gegenstelle nennt sie beim Anmelden.
credentialId String @unique
/// Öffentlicher Schlüssel im COSE-Format.
publicKey Bytes
/// Signaturzähler. Zählt er nicht hoch, wurde die Antwort wiedereingespielt.
counter BigInt @default(0)
/// usb, nfc, ble, internal, hybrid — hilft dem Browser bei der Auswahl.
transports String[] @default([])
/// singleDevice oder multiDevice. Ein nicht gesicherter Passkey ist mit dem
/// Gerät verloren — davor warnt die Oberfläche.
deviceType String @default("singleDevice")
backedUp Boolean @default(false)
/// Vom Nutzer vergeben: „iPhone", „Yubikey am Schlüsselbund".
name String
createdAt DateTime @default(now())
lastUsedAt DateTime?
@@index([userId])
}
/// Instanzweite Einstellungen (Registrierung offen/zu, Instanzname …)
model AppSetting {
key String @id
@@ -175,6 +212,7 @@ model Title {
watches Watch[]
providers TitleProvider[]
universeItems UniverseItem[]
serverItems MediaServerItem[]
@@unique([type, tmdbId])
@@index([type])
@@ -218,7 +256,8 @@ model Episode {
airDate DateTime?
runtime Int?
watches Watch[]
watches Watch[]
serverItems MediaServerItem[]
@@unique([titleId, seasonNumber, episodeNumber])
@@index([titleId])
@@ -359,6 +398,11 @@ model Watch {
run Int @default(0)
watchedAt DateTime @default(now())
/// Woher die Sichtung stammt. Ohne diese Angabe liesse sich spaeter nicht
/// mehr sagen, was von einem Medienserver kam — und ein Zuruecknehmen des
/// Abgleichs waere unmoeglich.
source WatchSource @default(FLIMMER)
/// Tatsächlich angerechnete Minuten — friert die Laufzeit zum Zeitpunkt der
/// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen.
minutes Int @default(0)
@@ -516,3 +560,99 @@ model SyncRun {
@@index([kind, startedAt])
}
// ---------------------------------------------------------------------------
// Medienserver (Jellyfin, Emby)
// ---------------------------------------------------------------------------
enum WatchSource {
FLIMMER
JELLYFIN
}
enum MediaServerKind {
JELLYFIN
EMBY
}
/// Verbindung zu einem Medienserver. Je Nutzer und Server eine.
model MediaServerConnection {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kind MediaServerKind @default(JELLYFIN)
/// Anzeigename des Servers, wie ihn Jellyfin meldet.
name String @default("Jellyfin")
baseUrl String
/// Zugangstoken, mit AUTH_SECRET verschluesselt — im Klartext hat er hier
/// nichts verloren.
token String
/// Kennung des Jellyfin-Nutzers, dessen Bestand und Sichtungen zaehlen.
serverUserId String
/// Anmeldename, nur zur Anzeige.
serverUserName String @default("")
enabled Boolean @default(true)
syncOwned Boolean @default(true)
syncWatched Boolean @default(true)
/// Stufe 3: Haekchen aus Flimmer zurueck nach Jellyfin schreiben.
writeBack Boolean @default(false)
lastSyncAt DateTime?
lastSyncReport Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items MediaServerItem[]
@@unique([userId, baseUrl])
@@index([userId])
}
/// Bereits zugeordnete Items. Macht Folgelaeufe billig und ist die
/// Voraussetzung fuers Zurueckschreiben.
model MediaServerItem {
id String @id @default(cuid())
connectionId String
connection MediaServerConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
/// Item-Kennung auf dem Medienserver.
externalId String
titleId String?
title Title? @relation(fields: [titleId], references: [id], onDelete: Cascade)
episodeId String?
episode Episode? @relation(fields: [episodeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([connectionId, externalId])
@@index([connectionId])
}
// ---------------------------------------------------------------------------
// Errungenschaften
// ---------------------------------------------------------------------------
/// Ein erreichtes Abzeichen. Einmal vergeben, bleibt es — auch wenn die
/// Bedingung später nicht mehr erfuellt waere.
model Achievement {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Stabiler Schluessel: "serientaeter-1000", "reihe:556", "universum:<id>".
key String
/// Bei dynamisch benannten Abzeichen der Name zum Zeitpunkt des Erreichens.
/// Eingefroren: ein umbenanntes Universum soll das Abzeichen nicht aendern.
label String?
unlockedAt DateTime @default(now())
@@unique([userId, key])
@@index([userId, unlockedAt])
}
+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.
+91
View File
@@ -0,0 +1,91 @@
# Flimmer 0.9.0
**Errungenschaften** — Abzeichen für erreichte Meilensteine. Dazu die
**Auswahlliste** beim Jellyfin-Abgleich, ohne die die Anbindung auf einer
gewachsenen Mediathek kaum etwas ausrichtete.
> ⚠️ **Beim Aktualisieren kommt eine Migration mit** (`errungenschaften`). Der
> Container spielt sie beim Start selbst ein; `./update.sh` legt vorher eine
> Sicherung an.
### Hinzugefügt
- **Errungenschaften.** Zwanzig feste Abzeichen, dazu dynamische für
vollständig gesehene Filmreihen und abgeschlossene Universen. Zu finden
unten auf der Statistikseite.
Sie tauchen auf, wenn etwas erreicht ist, und bleiben dann — **keine
Streaks**, die reißen, wenn man eine Woche nichts schaut, keine
Fortschrittsbalken, die drängeln. Wer eine Sichtung zurücknimmt, verliert
kein Abzeichen; es wieder einzukassieren wäre kleinlich.
Ein paar davon: *Der lange Atem* (eine Serie mit hundert Episoden komplett),
*Treu geblieben* (über ein Jahr an demselben Titel), *Durchgemacht*
(zwischen drei und fünf Uhr morgens abgehakt), *Zeitreisender* (Titel aus
sieben Jahrzehnten), *Strenges Urteil* (einmal die Note 1 vergeben).
Für **ganze Filmreihen** braucht es keine gepflegte Liste: TMDB gruppiert
sie selbst, und die Zugehörigkeit steht beim Import schon am Titel. Wer alle
Spider-Man-Filme gesehen hat, bekommt sie als Reihe angerechnet. Für
bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht „Reihe
komplett — …".
Beim ersten Öffnen wird die ganze Historie einmal **stumm** ausgewertet —
eine gewachsene Bibliothek soll nicht zwei Dutzend Meldungen auf einmal
auslösen. Erst danach wird gemeldet, was neu dazukommt. Auf dem öffentlichen
Profil lassen sich die Abzeichen zeigen oder ausblenden.
- **Auswahlliste für Jellyfin-Titel, die Flimmer nicht kennt.** Beim ersten
Abgleich einer gewachsenen Mediathek passierte bisher etwas Ernüchterndes:
Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen einzigen** zu,
weil der Katalog sie nicht kennt.
Jetzt steht unter dem Bericht eine Liste zum Anhaken. Was ausgewählt ist,
holt Flimmer von TMDB — bei Serien samt Staffeln und Episoden — und nimmt es
mit „hab ich · Jellyfin" in die Bibliothek auf.
Die Liste fasst zusammen: Führt Jellyfin drei Dateien zum selben Film
(Extended Edition, zweite Fassung), steht er einmal da, mit dem Vermerk, wie
viele Dateien darauf zeigen. Einträge, denen in Jellyfin die TMDB-Kennung
fehlt, bleiben sichtbar, sind aber nicht auswählbar — dort kann Flimmer
nichts holen.
Geholt werden hundert je Lauf; der Rest bleibt für den nächsten Klick. Ein
einzelner Fehlschlag beendet den Lauf nicht, und **aus der Liste verschwindet
nur, was tatsächlich ankam**.
### Geändert
- Der Bericht des Medienserver-Abgleichs war auf **50 Einträge** gekürzt —
bei über tausend gelesenen ein Blick durchs Schlüsselloch. Jetzt bis 1000,
und was darüber hinausgeht, wird gezählt statt stillschweigend
abgeschnitten.
- Das Häkchen beim Abgleich heißt jetzt „**Bekannte** Titel dabei in die
Bibliothek aufnehmen". Es nimmt auf, was Flimmer *kennt*; die neue
Auswahlliste holt, was es *nicht kennt*. Der Unterschied stand vorher
nirgends — und war der Grund, warum das Häkchen bei einer frischen Instanz
nichts bewirkte.
- Einblendungen kennen einen dritten Ton für Erfolge.
- `npm run screenshots` hebt für Ausschnittaufnahmen die klebende Kopfleiste
auf; sie legte sich sonst über die Überschrift.
### Behoben
- Im Bericht stand eine Serie als **Film**, weil der Typ aus dem Umkehrschluss
„keine Episode, also Film" abgeleitet wurde. Er kommt jetzt aus dem Item.
### Getestet
93 Tests (vorher 78). Neu: der Regelkatalog der Errungenschaften samt
Stummschaltung, Entdopplung und Zeitzonenrechnung; die Auswahlliste samt
Zusammenfassung, Fehlschlägen und Rechteprüfung.
Ein Test hat dabei einen Fehler gefunden, den man leicht übersieht: Die Regel
„zwischen drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette,
und auf Deutsch liefert das System dafür **„03 Uhr"** — als Zahl gelesen wird
daraus nichts. Das Abzeichen wäre nie vergeben worden.
### Anleitung
`docs/ANLEITUNG.md` hat zwei neue Abschnitte: **Errungenschaften** mit
Screenshot und **Titel, die Flimmer noch nicht kennt** im Jellyfin-Kapitel.
+136
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,73 @@ 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);
// Die Kopfleiste klebt oben und legt sich sonst über die Überschrift des
// Ausschnitts. Fürs Bild kurz aufheben.
await page.addStyleTag({ content: "header { position: static !important; }" }).catch(() => {});
await page.waitForTimeout(200);
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 +146,74 @@ 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();
}
// --- Errungenschaften ---
{
const { ctx, page } = await session(1400, 1200, "dark");
await login(page);
await page.goto(`${BASE}/statistik`, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
await ausschnitt(page, "23-errungenschaften", "Errungenschaften");
await ctx.close();
}
// --- Hell ---
{
const { ctx, page } = await session(1400, 900, "light");
+11 -4
View File
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/lib/auth";
import { getSettings, isRegistrationOpen } from "@/lib/settings";
import { env } from "@/lib/env";
import { AuthSwitch, LoginForm } from "@/components/AuthForm";
import { PasskeyLoginButton } from "@/components/settings/Passkeys";
import { loginAction } from "../actions";
export const metadata: Metadata = { title: "Anmelden" };
@@ -36,16 +37,22 @@ export default async function LoginPage({
<LoginForm action={loginAction} weiter={params.weiter} />
{env.oidc.isConfigured && (
{(env.passkeys.isAvailable || env.oidc.isConfigured) && (
<>
<div className="my-5 flex items-center gap-3 text-xs text-[var(--fl-faint)]">
<span className="h-px flex-1 bg-[var(--fl-border)]" />
oder
<span className="h-px flex-1 bg-[var(--fl-border)]" />
</div>
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full">
{env.oidc.buttonLabel}
</a>
<div className="space-y-2">
{env.passkeys.isAvailable && <PasskeyLoginButton label="Mit Passkey anmelden" />}
{env.oidc.isConfigured && (
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full justify-center">
{env.oidc.buttonLabel}
</a>
)}
</div>
</>
)}
+40 -9
View File
@@ -17,12 +17,14 @@ import {
startRewatch,
undoWatches,
unmarkEpisodes,
unmarkMovie,
unmarkSeason,
updateEntryDetails,
watchNextEpisode,
type WatchResult,
} from "@/lib/library";
import { prisma } from "@/lib/db";
import { achievementName, evaluateAchievements } from "@/lib/achievements";
/**
* Server-Aktionen rund um die Bibliothek.
@@ -37,11 +39,14 @@ export type ActionResult = {
message?: string;
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
watchIds?: string[];
/** Gerade erreichte Errungenschaften, fertig benannt. */
achievements?: string[];
};
async function run(
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
revalidate: string[] = [],
{ pruefeErrungenschaften = false }: { pruefeErrungenschaften?: boolean } = {},
): Promise<ActionResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
@@ -49,7 +54,25 @@ async function run(
try {
const result = (await fn(user.id)) ?? {};
for (const path of revalidate) revalidatePath(path, "layout");
return { ok: true, message: result.message, watchIds: result.watchIds };
// Nur nach Sichtungsaktionen nachsehen — eine Tag-Änderung kann kein
// Abzeichen auslösen, und die Auswertung liest die ganze Sichtungshistorie.
let achievements: string[] | undefined;
if (pruefeErrungenschaften) {
try {
const ergebnis = await evaluateAchievements(user.id);
if (ergebnis.neu.length > 0) {
achievements = ergebnis.neu.map((eintrag) =>
achievementName(eintrag.key, eintrag.label),
);
}
} catch (error) {
// Ein Abzeichen ist die Kür. Es darf das Abhaken nie scheitern lassen.
console.error("Errungenschaften konnten nicht ausgewertet werden:", error);
}
}
return { ok: true, message: result.message, watchIds: result.watchIds, achievements };
} catch (error) {
if (error instanceof LibraryError) return { ok: false, message: error.message };
console.error("Bibliotheksaktion fehlgeschlagen:", error);
@@ -74,7 +97,7 @@ export async function addTitleAction(titleId: string, status: string = "WATCHLIS
message:
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
};
}, PATHS);
}, PATHS, { pruefeErrungenschaften: true });
}
export async function trashEntryAction(entryId: string) {
@@ -129,7 +152,7 @@ export async function startRewatchAction(entryId: string) {
return run(async (userId) => {
const entry = await startRewatch(userId, cuid.parse(entryId));
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` };
}, PATHS);
}, PATHS, { pruefeErrungenschaften: true });
}
// ---------------------------------------------------------------------------
@@ -147,7 +170,7 @@ export async function toggleEpisodeAction(entryId: string, episodeId: string, wa
}
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
return { message: "Markierung entfernt" };
}, PATHS);
}, PATHS, { pruefeErrungenschaften: true });
}
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
@@ -157,29 +180,37 @@ export async function toggleSeasonAction(entryId: string, seasonId: string, watc
}
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
return { message: "Staffel zurückgesetzt" };
}, PATHS);
}, PATHS, { pruefeErrungenschaften: true });
}
export async function markUpToAction(entryId: string, episodeId: string) {
return run(
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
PATHS,
{ pruefeErrungenschaften: true },
);
}
export async function watchNextAction(entryId: string) {
return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS);
return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS, { pruefeErrungenschaften: true });
}
export async function markMovieAction(entryId: string) {
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS, { pruefeErrungenschaften: true });
}
export async function unmarkMovieAction(entryId: string) {
return run(async (userId) => {
await unmarkMovie(userId, cuid.parse(entryId));
return { message: "Markierung entfernt" };
}, PATHS, { pruefeErrungenschaften: true });
}
export async function undoAction(entryId: string, watchIds: string[]) {
return run(async (userId) => {
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
return { message: "Rückgängig gemacht" };
}, PATHS);
}, PATHS, { pruefeErrungenschaften: true });
}
// ---------------------------------------------------------------------------
@@ -226,7 +257,7 @@ export async function setTagsAction(entryId: string, tagNames: string[]) {
]);
return { message: "Tags gespeichert" };
}, PATHS);
}, PATHS, { pruefeErrungenschaften: true });
}
// ---------------------------------------------------------------------------
+155
View File
@@ -15,6 +15,15 @@ import { setSetting } from "@/lib/settings";
import { sendTestNotification } from "@/lib/notify";
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
import { runSync } from "@/lib/sync";
import {
connectJellyfin,
deleteConnection,
importUnassigned,
syncConnection,
testConnection,
updateConnection,
type SyncReport,
} from "@/lib/mediaserver";
/** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */
@@ -56,6 +65,7 @@ const profileSchema = z.object({
name: z.string().trim().min(1).max(80),
region: z.string().trim().length(2).toUpperCase(),
publicProfile: z.boolean(),
showAchievements: z.boolean(),
profileSlug: z
.string()
.trim()
@@ -304,3 +314,148 @@ export async function runSyncAction() {
};
});
}
// ---------------------------------------------------------------------------
// Medienserver
// ---------------------------------------------------------------------------
const verbindungSchema = z.object({
baseUrl: z.string().trim().min(1).max(300),
username: z.string().trim().min(1).max(120),
password: z.string().min(1).max(300),
});
export type MediaServerResult = SettingsResult & {
pruefung?: {
serverName: string;
version: string | null;
itemCount: number;
hasUserData: boolean;
withTmdbId: number;
};
bericht?: SyncReport;
};
export async function connectMediaServerAction(input: {
baseUrl: string;
username: string;
password: string;
}): Promise<MediaServerResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
const daten = verbindungSchema.safeParse(input);
if (!daten.success) return { ok: false, message: "Adresse, Name und Passwort werden gebraucht." };
try {
const { pruefung } = await connectJellyfin(user.id, daten.data);
revalidatePath("/einstellungen", "layout");
return {
ok: true,
message: `Verbunden mit ${pruefung.serverName}`,
pruefung,
};
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
};
}
}
export async function testMediaServerAction(connectionId: string): Promise<MediaServerResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
try {
const pruefung = await testConnection(user.id, connectionId);
return { ok: true, message: `${pruefung.serverName} antwortet`, pruefung };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
};
}
}
export async function syncMediaServerAction(
connectionId: string,
aufnehmen: boolean,
): Promise<MediaServerResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
try {
const bericht = await syncConnection(user.id, connectionId, { aufnehmen });
revalidatePath("/einstellungen", "layout");
revalidatePath("/bibliothek", "layout");
return {
ok: true,
message:
`${bericht.gelesen} Einträge gelesen, ${bericht.bestandGesetzt}× „hab ich“ gesetzt, ` +
`${bericht.sichtungenAngelegt} Sichtungen übernommen`,
bericht,
};
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
};
}
}
const auswahlSchema = z
.array(
z.object({
tmdbId: z.number().int().positive(),
typ: z.enum(["SERIES", "MOVIE"]),
}),
)
.min(1)
.max(1000);
/** Holt die ausgewählten Titel aus TMDB nach. */
export async function importMediaServerTitlesAction(
connectionId: string,
auswahl: unknown,
): Promise<MediaServerResult & { uebrig?: number }> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
const daten = auswahlSchema.safeParse(auswahl);
if (!daten.success) return { ok: false, message: "Es war nichts ausgewählt." };
try {
const ergebnis = await importUnassigned(user.id, connectionId, daten.data);
revalidatePath("/einstellungen", "layout");
revalidatePath("/bibliothek", "layout");
const teile = [`${ergebnis.nachgeladen} Titel nachgeladen`];
if (ergebnis.fehler.length > 0) teile.push(`${ergebnis.fehler.length} fehlgeschlagen`);
if (ergebnis.uebrig > 0) teile.push(`${ergebnis.uebrig} bleiben für den nächsten Lauf`);
return { ok: true, message: teile.join(", "), uebrig: ergebnis.uebrig };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
};
}
}
export async function updateMediaServerAction(
connectionId: string,
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
) {
return run(async (userId) => {
await updateConnection(userId, connectionId, data);
return { message: "Gespeichert" };
});
}
export async function deleteMediaServerAction(connectionId: string) {
return run(async (userId) => {
await deleteConnection(userId, connectionId);
return { message: "Verbindung entfernt" };
});
}
+8
View File
@@ -12,6 +12,7 @@ import {
removeTitle,
renameOrder,
renameUniverse,
setDefaultOrder,
setOrderPositions,
shareUniverse,
trashUniverse,
@@ -124,6 +125,13 @@ export async function deleteOrderAction(orderId: string) {
});
}
export async function setDefaultOrderAction(orderId: string) {
return run(async (userId) => {
await setDefaultOrder(userId, id.parse(orderId));
return { message: "Standardreihenfolge gesetzt" };
});
}
export async function reorderAction(orderId: string, itemIds: string[]) {
return run(async (userId) => {
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createSession } from "@/lib/auth";
import { finishLogin, PasskeyError } from "@/lib/passkeys";
export const dynamic = "force-dynamic";
const schema = z.looseObject({
id: z.string().min(1),
rawId: z.string().min(1),
type: z.literal("public-key"),
clientExtensionResults: z.looseObject({}),
response: z.looseObject({}),
});
/** Prüft die Antwort und legt bei Erfolg die Sitzung an. */
export async function POST(request: Request) {
const body = schema.safeParse(await request.json().catch(() => null));
if (!body.success) {
return NextResponse.json({ error: "Die Anfrage war unvollständig." }, { status: 422 });
}
try {
// Die äußere Form ist geprüft; alles Weitere prüft die Bibliothek.
const { userId } = await finishLogin(body.data as unknown as Parameters<typeof finishLogin>[0]);
// Dieselbe Sitzung wie nach einer Passwortanmeldung — gleiche Tabelle,
// gleiches Cookie.
await createSession(userId);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof PasskeyError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Passkey-Anmeldung fehlgeschlagen:", error);
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
}
}
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { PasskeyError, startLogin } from "@/lib/passkeys";
export const dynamic = "force-dynamic";
/**
* Optionen für die Anmeldung — bewusst ohne Anmeldung erreichbar und ohne
* Angabe, welche Passkeys es gibt. Der Browser sucht selbst einen passenden.
*/
export async function POST() {
try {
return NextResponse.json(await startLogin());
} catch (error) {
if (error instanceof PasskeyError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Passkey-Anmeldung konnte nicht beginnen:", error);
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
}
}
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/auth";
import { finishRegistration, PasskeyError } from "@/lib/passkeys";
export const dynamic = "force-dynamic";
const antwortSchema = z.looseObject({
id: z.string().min(1),
rawId: z.string().min(1),
type: z.literal("public-key"),
clientExtensionResults: z.looseObject({}),
response: z.looseObject({}),
});
const schema = z.object({
name: z.string().trim().min(1).max(60),
response: antwortSchema,
});
/** Prüft die Antwort des Authenticators und legt den Passkey an. */
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
const body = schema.safeParse(await request.json().catch(() => null));
if (!body.success) {
return NextResponse.json({ error: "Die Anfrage war unvollständig." }, { status: 422 });
}
try {
// Die äußere Form ist geprüft; die Feinheiten der eingebetteten
// Base64-Felder prüft die Bibliothek beim Verifizieren.
const passkey = await finishRegistration(
user.id,
body.data.response as unknown as Parameters<typeof finishRegistration>[1],
body.data.name,
);
return NextResponse.json({ ok: true, passkey });
} catch (error) {
if (error instanceof PasskeyError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Passkey konnte nicht angelegt werden:", error);
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
}
}
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/auth";
import { PasskeyError, startRegistration } from "@/lib/passkeys";
export const dynamic = "force-dynamic";
/** Optionen zum Anlegen eines Passkeys — nur für angemeldete Nutzer. */
export async function POST() {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
try {
return NextResponse.json(await startRegistration(user.id));
} catch (error) {
if (error instanceof PasskeyError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Passkey-Registrierung konnte nicht beginnen:", error);
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/auth";
import { deletePasskey, PasskeyError, renamePasskey } from "@/lib/passkeys";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
async function withUser(context: Context, fn: (userId: string, id: string) => Promise<unknown>) {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
const { id } = await context.params;
if (!id) return NextResponse.json({ error: "Es fehlt die Kennung." }, { status: 400 });
try {
await fn(user.id, id);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof PasskeyError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Passkey-Verwaltung fehlgeschlagen:", error);
return NextResponse.json({ error: "Das hat nicht geklappt." }, { status: 500 });
}
}
/** Entfernt einen Passkey — nie den letzten Anmeldeweg. */
export async function DELETE(_request: Request, context: Context) {
return withUser(context, (userId, id) => deletePasskey(userId, id));
}
const umbenennen = z.object({ name: z.string().trim().min(1).max(60) });
/** Benennt einen Passkey um. */
export async function PATCH(request: Request, context: Context) {
const body = umbenennen.safeParse(await request.json().catch(() => null));
if (!body.success) {
return NextResponse.json({ error: "Es fehlt ein Name." }, { status: 422 });
}
return withUser(context, (userId, id) => renamePasskey(userId, id, body.data.name));
}
@@ -8,6 +8,7 @@ import {
markUpTo,
undoWatches,
unmarkEpisodes,
unmarkMovie,
watchNextEpisode,
} from "@/lib/library";
@@ -75,9 +76,17 @@ export const POST = withApi(async (request, user, context) => {
const deleteSchema = z.union([
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
z.object({ action: z.literal("movie") }),
]);
/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */
/**
* DELETE — Sichtungen zurücknehmen.
*
* Drei Wege: gezielt über `watchIds` (die Antwort eines POST), über
* `episodeIds`, oder mit `{"action":"movie"}` als Gegenstück zum
* gleichnamigen POST — dafür braucht man die `watchIds` nicht zu kennen.
* Zurückgenommen wird immer nur der laufende Durchlauf.
*/
export const DELETE = withApi(async (request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
@@ -86,8 +95,10 @@ export const DELETE = withApi(async (request, user, context) => {
if ("watchIds" in body) {
await undoWatches(user.id, entryId, body.watchIds);
} else {
} else if ("episodeIds" in body) {
await unmarkEpisodes(user.id, entryId, body.episodeIds);
} else {
await unmarkMovie(user.id, entryId);
}
return { ok: true };
});
+40
View File
@@ -210,6 +210,11 @@ export function GET() {
},
delete: {
summary: "Sichtungen zurücknehmen",
description:
"Gezielt über watchIds, über episodeIds, oder mit action=movie als Gegenstück " +
"zum gleichnamigen POST — dafür braucht man die watchIds nicht zu kennen. " +
"Zurückgenommen wird immer nur der laufende Durchlauf; frühere Durchläufe " +
"bleiben für die Statistik erhalten.",
requestBody: {
required: true,
content: {
@@ -226,6 +231,11 @@ export function GET() {
properties: { episodeIds: { type: "array", items: { type: "string" } } },
required: ["episodeIds"],
},
{
type: "object",
properties: { action: { const: "movie" } },
required: ["action"],
},
],
},
},
@@ -287,6 +297,9 @@ export function GET() {
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
get: {
summary: "Universum mit Reihenfolge und Fortschritt",
description:
"Ohne 'reihenfolge' gilt die Standardreihenfolge des Universums — sie trägt in " +
"'orders' das Kennzeichen 'isDefault'.",
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
},
@@ -306,6 +319,33 @@ export function GET() {
},
responses: { "200": { description: "Aufgenommen" } },
},
patch: {
summary: "Standardreihenfolge festlegen",
description:
"Bestimmt, welche Reihenfolge beim Öffnen erscheint. Braucht Schreibrecht; je " +
"Universum trägt immer genau eine Reihenfolge den Standard.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
defaultOrderId: {
type: "string",
description: "Muss eine Reihenfolge dieses Universums sein.",
},
},
required: ["defaultOrderId"],
},
},
},
},
responses: {
"200": { description: "Gesetzt" },
"404": { description: "Die Reihenfolge gehört nicht zu diesem Universum" },
},
},
},
"/export": {
get: {
+24 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { apiError, readJson, withApi } from "@/lib/api";
import { addTitle, getUniverse } from "@/lib/universes";
import { prisma } from "@/lib/db";
import { addTitle, getUniverse, setDefaultOrder } from "@/lib/universes";
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
export const GET = withApi(async (request, user, context) => {
@@ -25,3 +26,25 @@ export const POST = withApi(async (request, user, context) => {
await addTitle(user.id, id, body.titleId);
return { ok: true };
});
const patchSchema = z.object({ defaultOrderId: z.string().min(1) });
/** PATCH /api/v1/universen/{id} — festlegen, welche Reihenfolge beim Öffnen erscheint. */
export const PATCH = withApi(async (request, user, context) => {
const { id } = await context.params;
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
const body = await readJson(request, patchSchema);
// Die Reihenfolge muss zu genau diesem Universum gehören. Ohne diese Prüfung
// setzte ein PATCH auf Universum A den Standard in Universum B — die
// Domänenschicht leitet das Universum ja aus der Reihenfolge ab.
const order = await prisma.universeOrder.findFirst({
where: { id: body.defaultOrderId, universeId: id },
select: { id: true },
});
if (!order) return apiError("Diese Reihenfolge gehört nicht zu diesem Universum.", 404);
await setDefaultOrder(user.id, order.id);
return { ok: true };
});
+35 -30
View File
@@ -6,6 +6,7 @@ import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type Libr
import { AppShell } from "@/components/AppShell";
import { LibraryCard } from "@/components/library/LibraryCard";
import { LibraryFilters } from "@/components/library/LibraryFilters";
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
export const metadata: Metadata = { title: "Bibliothek" };
@@ -56,39 +57,43 @@ export default async function LibraryPage({
</Link>
</header>
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
<SearchPendingProvider>
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
<div className="mt-6">
{items.length === 0 ? (
<div className="fl-card p-8 text-center">
<p className="text-[var(--fl-muted)]">
{facets.counts.ALLE === 0
? "Deine Bibliothek ist noch leer."
: hasFilters
? "Zu dieser Auswahl passt nichts."
: "Hier ist nichts."}
</p>
{facets.counts.ALLE === 0 && (
<Link href="/suche" className="fl-btn fl-btn-primary mt-4">
Ersten Titel suchen
</Link>
<SearchResults>
<div className="mt-6">
{items.length === 0 ? (
<div className="fl-card p-8 text-center">
<p className="text-[var(--fl-muted)]">
{facets.counts.ALLE === 0
? "Deine Bibliothek ist noch leer."
: hasFilters
? "Zu dieser Auswahl passt nichts."
: "Hier ist nichts."}
</p>
{facets.counts.ALLE === 0 && (
<Link href="/suche" className="fl-btn fl-btn-primary mt-4">
Ersten Titel suchen
</Link>
)}
</div>
) : (
<>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Titel" : "Titel"}
</p>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((item) => (
<li key={item.entryId}>
<LibraryCard item={item} />
</li>
))}
</ul>
</>
)}
</div>
) : (
<>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Titel" : "Titel"}
</p>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((item) => (
<li key={item.entryId}>
<LibraryCard item={item} />
</li>
))}
</ul>
</>
)}
</div>
</SearchResults>
</SearchPendingProvider>
</AppShell>
);
}
+60
View File
@@ -3,6 +3,11 @@ import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { PasswordForm, ProfileForm } from "@/components/settings/Forms";
import { PasskeyList } from "@/components/settings/Passkeys";
import { listPasskeys } from "@/lib/passkeys";
import { MediaServerSettings } from "@/components/settings/MediaServer";
import { listConnections } from "@/lib/mediaserver";
import { env } from "@/lib/env";
import pkg from "../../../package.json";
export const metadata: Metadata = { title: "Einstellungen" };
@@ -19,6 +24,7 @@ export default async function SettingsPage() {
region: true,
publicProfile: true,
profileSlug: true,
showAchievements: true,
notifyNewEpisode: true,
notifyRelease: true,
notifyAvailability: true,
@@ -28,6 +34,9 @@ export default async function SettingsPage() {
const { passwordHash, ...profile } = user;
const passkeys = env.passkeys.enabled ? await listPasskeys(session.id) : [];
const medienserver = await listConnections(session.id);
return (
<div className="space-y-6">
<section className="fl-card p-5">
@@ -42,6 +51,57 @@ export default async function SettingsPage() {
</section>
)}
{env.passkeys.enabled && (
<section className="fl-card p-5">
<h2 className="mb-1 text-lg font-semibold">Passkeys</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Anmelden ohne Passwort — per Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
</p>
<PasskeyList
passkeys={passkeys.map((passkey) => ({
...passkey,
createdAt: passkey.createdAt.toISOString(),
lastUsedAt: passkey.lastUsedAt?.toISOString() ?? null,
}))}
available={env.passkeys.isAvailable}
hinweis={
env.passkeys.isAvailable
? null
: "Passkeys brauchen eine verschlüsselte Verbindung. Diese Instanz läuft über " +
"einfaches HTTP — richte HTTPS ein, dann erscheint hier die Verwaltung."
}
/>
</section>
)}
<section className="fl-card p-5">
<h2 className="mb-1 text-lg font-semibold">Medienserver</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Bestand und Sichtungen aus Jellyfin übernehmen. Der Abgleich ergänzt nur — er löscht
nie etwas, das in Flimmer steht.
</p>
<MediaServerSettings
verbindungen={medienserver.map((verbindung) => ({
id: verbindung.id,
name: verbindung.name,
baseUrl: verbindung.baseUrl,
serverUserName: verbindung.serverUserName,
enabled: verbindung.enabled,
syncOwned: verbindung.syncOwned,
syncWatched: verbindung.syncWatched,
lastSyncAt: verbindung.lastSyncAt?.toISOString() ?? null,
// Der letzte Bericht liegt als Json in der Datenbank; daraus
// speist sich die Auswahlliste, damit sie einen Seitenwechsel
// übersteht.
offen: (verbindung.lastSyncReport as { nichtZugeordnet?: unknown[] } | null)
?.nichtZugeordnet as never ?? [],
weitereOffen:
((verbindung.lastSyncReport as { weitereNichtZugeordnet?: number } | null)
?.weitereNichtZugeordnet ?? 0),
}))}
/>
</section>
<p className="text-xs text-[var(--fl-faint)]">
Flimmer {pkg.version} · Instanz {settings.instanceName}
</p>
+51
View File
@@ -16,6 +16,8 @@ import { formatMinutes } from "@/lib/labels";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { DistributionBars, MonthlyChart } from "@/components/stats/Charts";
import { AchievementBadge } from "@/components/AchievementBadge";
import { GRUPPEN, ensureAchievementsEvaluated, listAchievements } from "@/lib/achievements";
export const metadata: Metadata = { title: "Statistik" };
@@ -42,6 +44,13 @@ export default async function StatsPage({
getYearReview(user.id, year),
]);
// Beim allerersten Aufruf einmal auswerten: sonst bliebe die Liste leer,
// bis nachts der Hintergrundlauf kommt — und wer sie öffnet, will sehen,
// was er hat, nicht auf morgen vertröstet werden. Der Lauf ist stumm und
// passiert genau einmal je Konto.
await ensureAchievementsEvaluated(user.id);
const abzeichen = await listAchievements(user.id);
if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) {
return (
<AppShell user={user} instanceName={settings.instanceName}>
@@ -205,6 +214,48 @@ export default async function StatsPage({
emptyText="Noch nichts bewertet."
/>
</div>
<section className="fl-card mt-6 p-5" aria-labelledby="errungenschaften">
<h2 id="errungenschaften" className="text-lg font-semibold">
Errungenschaften
</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
{abzeichen.filter((eintrag) => eintrag.unlockedAt).length} von {abzeichen.length}{" "}
erreicht. Was hier steht, bleibt stehen — auch wenn du eine Sichtung zurücknimmst.
</p>
<div className="space-y-5">
{GRUPPEN.map((gruppe) => {
const eintraege = abzeichen.filter((eintrag) => eintrag.gruppe === gruppe.key);
if (eintraege.length === 0) return null;
// Erreichte zuerst — man will sehen, was man hat, nicht was fehlt.
const sortiert = [...eintraege].sort((a, b) => {
if (Boolean(a.unlockedAt) !== Boolean(b.unlockedAt)) return a.unlockedAt ? -1 : 1;
return a.name.localeCompare(b.name, "de-DE");
});
return (
<div key={gruppe.key}>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
{gruppe.label}
</h3>
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{sortiert.map((eintrag) => (
<AchievementBadge
key={eintrag.key}
name={eintrag.name}
beschreibung={eintrag.beschreibung}
symbol={eintrag.symbol}
unlockedAt={eintrag.unlockedAt?.toISOString() ?? null}
/>
))}
</ul>
</div>
);
})}
</div>
</section>
</AppShell>
);
}
+25 -20
View File
@@ -7,6 +7,7 @@ import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { SearchField } from "@/components/SearchField";
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
export const metadata: Metadata = { title: "Suchen" };
@@ -54,27 +55,31 @@ export default async function SearchPage({
</p>
</header>
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
<SearchPendingProvider>
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
<p className="mt-3 text-sm text-[var(--fl-faint)]">
Nichts dabei?{" "}
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
Titel selbst anlegen
</Link>
</p>
<p className="mt-3 text-sm text-[var(--fl-faint)]">
Nichts dabei?{" "}
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
Titel selbst anlegen
</Link>
</p>
<div className="mt-8">
{!env.tmdb.isConfigured ? (
<Notice tone="warning">
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "}
<code>.env</code> ein und starte die Instanz neu.
</Notice>
) : query.length < 2 ? (
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
) : (
<Results query={query} filter={filter} />
)}
</div>
<SearchResults>
<div className="mt-8">
{!env.tmdb.isConfigured ? (
<Notice tone="warning">
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in
der <code>.env</code> ein und starte die Instanz neu.
</Notice>
) : query.length < 2 ? (
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
) : (
<Results query={query} filter={filter} />
)}
</div>
</SearchResults>
</SearchPendingProvider>
</AppShell>
);
}
@@ -119,7 +124,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
>
<Poster path={item.posterPath} alt={item.title} size="w342" />
<div className="mt-2 space-y-0.5">
<p className="line-clamp-2 text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
<p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
{item.title}
</p>
<p className="text-xs text-[var(--fl-faint)]">
+23 -1
View File
@@ -2,6 +2,8 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { listUnlockedAchievements } from "@/lib/achievements";
import { AchievementSymbol } from "@/components/AchievementBadge";
import { getSettings } from "@/lib/settings";
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
import { formatMinutes, formatRelative } from "@/lib/labels";
@@ -35,10 +37,12 @@ export default async function PublicProfilePage({ params }: { params: Params })
const user = await prisma.user.findFirst({
where: { profileSlug: slug, publicProfile: true, isActive: true },
select: { id: true, name: true, createdAt: true },
select: { id: true, name: true, createdAt: true, showAchievements: true },
});
if (!user) notFound();
const abzeichen = user.showAchievements ? await listUnlockedAchievements(user.id) : [];
const [totals, mostWatched, topRated, recent] = await Promise.all([
getTotals(user.id),
getMostWatched(user.id, 6),
@@ -89,6 +93,24 @@ export default async function PublicProfilePage({ params }: { params: Params })
</div>
</header>
{abzeichen.length > 0 && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Errungenschaften</h2>
<ul className="flex flex-wrap gap-2">
{abzeichen.map((eintrag) => (
<li
key={eintrag.key}
title={eintrag.beschreibung}
className="inline-flex items-center gap-2 rounded-full border border-[color-mix(in_oklab,var(--fl-accent)_35%,transparent)] bg-[color-mix(in_oklab,var(--fl-accent)_8%,transparent)] px-3 py-1.5 text-sm"
>
<AchievementSymbol symbol={eintrag.symbol} className="h-4 w-4 text-[var(--fl-accent)]" />
{eintrag.name}
</li>
))}
</ul>
</section>
)}
{recent.length > 0 && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
+203
View File
@@ -0,0 +1,203 @@
import { clsx } from "clsx";
import type { SymbolName } from "@/lib/achievements";
/**
* Abzeichen mit Symbol.
*
* Die Symbole sind eigene SVG statt Emoji: Emoji sehen auf jedem System
* anders aus, lassen sich nicht einfärben und passen nicht zur Formsprache
* des Logos. Alle liegen auf demselben 24er-Raster, zeichnen nur mit
* `currentColor` und tragen keine Farbe für sich — die kommt vom Zustand.
*
* Erreicht und offen unterscheiden sich **nicht nur durch Farbe**: das offene
* Abzeichen ist blass *und* trägt den Hinweis „noch offen" im Text, das
* erreichte nennt sein Datum. Farbe allein wäre für Screenreader und bei
* Farbsehschwäche nichts wert.
*/
const PFADE: Record<SymbolName, React.ReactNode> = {
spross: (
<>
<path d="M12 21v-8" />
<path d="M12 13c0-3 2-5 5-5 0 3-2 5-5 5Z" />
<path d="M12 16c0-2.5-1.8-4.5-4.5-4.5 0 2.5 1.8 4.5 4.5 4.5Z" />
</>
),
haken: <path d="m4 12.5 5 5L20 6.5" />,
schluessel: (
<>
<circle cx="8" cy="12" r="3.5" />
<path d="M11.5 12H21m-3 0v3.5m-3-3.5v2.5" />
</>
),
haus: <path d="M3.5 11 12 4l8.5 7M6 9.5V20h12V9.5M10 20v-5.5h4V20" />,
klappe: (
<>
<rect x="3" y="9" width="18" height="11" rx="2" />
<path d="m3.5 9 3.5-4.5 3.5 4.5m0 0L14 4.5 17.5 9" />
</>
),
planet: (
<>
<circle cx="12" cy="12" r="6" />
<path d="M3.5 14.5c5 2.5 12 2.5 17 0" />
</>
),
kompass: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="m15.5 8.5-2 5.5-5.5 2 2-5.5Z" />
</>
),
bildschirm: (
<>
<rect x="3" y="5" width="18" height="12" rx="2" />
<path d="M8 21h8m-4-4v4" />
</>
),
filmrolle: (
<>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="8.5" r="1.6" />
<circle cx="15.4" cy="13.8" r="1.6" />
<circle cx="8.6" cy="13.8" r="1.6" />
</>
),
sonne: (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6l1.4 1.4m10 10 1.4 1.4m0-12.8-1.4 1.4m-10 10-1.4 1.4" />
</>
),
kalender: (
<>
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M3.5 10h17M8 3v4m8-4v4" />
</>
),
laeufer: (
<>
<circle cx="14" cy="5" r="2" />
<path d="m8 21 3-6 3 2 1 5M11 15l-2-4 4-3 3 3 3 .5" />
</>
),
berg: <path d="m2.5 19 6-11 4 6.5 2.5-4 6.5 8.5Z" />,
herz: <path d="M12 20s-7.5-4.6-7.5-9.4A4.1 4.1 0 0 1 12 8a4.1 4.1 0 0 1 7.5 2.6C19.5 15.4 12 20 12 20Z" />,
kreis: (
<>
<path d="M20 12a8 8 0 1 1-3.2-6.4" />
<path d="M20 4v5h-5" />
</>
),
maske: (
<>
<path d="M4 6h7v6a3.5 3.5 0 0 1-7 0Z" />
<path d="M13 6h7v6a3.5 3.5 0 0 1-7 0Z" />
<path d="M6.5 9h2m7 0h2" />
</>
),
sanduhr: (
<>
<path d="M7 3h10M7 21h10" />
<path d="M8 3v3.5L12 12l4-5.5V3M8 21v-3.5L12 12l4 5.5V21" />
</>
),
stern: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.7l5.9-.8Z" />,
salz: (
<>
<path d="M8.5 9h7l1 11h-9Z" />
<path d="M9.5 9V5.5h5V9M11 3h2" />
</>
),
scheibe: (
<>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="12" r="2.5" />
</>
),
besen: (
<>
<path d="M14 3 8.5 12.5" />
<path d="m5 14 6-3.5 4 6.5-6 3.5Z" />
<path d="m9 20.5 2.5 0.5m2-2 2.5.5m-.5-3.5 2.5.5" />
</>
),
mond: <path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z" />,
spinne: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 9V5m0 10v4m-3-7H5m14 0h-4M9.5 9.5 6.5 6.5m8 3 3-3m-8 8-3 3m8-3 3 3" />
</>
),
};
export function AchievementSymbol({
symbol,
className,
}: {
symbol: SymbolName;
className?: string;
}) {
return (
<svg
viewBox="0 0 24 24"
className={clsx("h-6 w-6", className)}
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{PFADE[symbol]}
</svg>
);
}
export function AchievementBadge({
name,
beschreibung,
symbol,
unlockedAt,
}: {
name: string;
beschreibung: string;
symbol: SymbolName;
unlockedAt: string | null;
}) {
const erreicht = unlockedAt !== null;
return (
<li
className={clsx(
"flex items-start gap-3 rounded-xl border p-3 transition-colors",
erreicht
? "border-[color-mix(in_oklab,var(--fl-accent)_35%,transparent)] bg-[color-mix(in_oklab,var(--fl-accent)_7%,transparent)]"
: "border-[var(--fl-border)]",
)}
>
<span
className={clsx("mt-0.5 shrink-0", erreicht ? "text-[var(--fl-accent)]" : "text-[var(--fl-faint)]")}
style={erreicht ? { filter: "drop-shadow(0 0 6px color-mix(in oklab, var(--fl-accent) 40%, transparent))" } : undefined}
>
<AchievementSymbol symbol={symbol} />
</span>
<div className="min-w-0">
<p className={clsx("font-medium", !erreicht && "text-[var(--fl-muted)]")}>{name}</p>
<p className="text-xs text-[var(--fl-faint)]">{beschreibung}</p>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{erreicht ? `erreicht am ${formatiere(unlockedAt)}` : "noch offen"}
</p>
</div>
</li>
);
}
function formatiere(wert: string): string {
return new Date(wert).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
+8 -9
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState, useTransition } from "react";
import { usePathname, useRouter } from "next/navigation";
import { clsx } from "clsx";
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
/**
* Suchfeld mit Typfilter.
@@ -26,6 +27,11 @@ export function SearchField({
const [pending, startTransition] = useTransition();
const first = useRef(true);
const reportPending = useReportSearchPending();
useEffect(() => {
reportPending(pending);
}, [pending, reportPending]);
useEffect(() => {
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
// gerade geladene URL durch eine identische.
@@ -41,7 +47,7 @@ export function SearchField({
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
});
}, 350);
}, 250);
return () => clearTimeout(timer);
}, [value, filter, pathname, router]);
@@ -68,14 +74,7 @@ export function SearchField({
autoFocus
className="fl-input pl-10 text-base"
/>
{pending && (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
)}
<SearchSpinner show={pending} />
</div>
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import { clsx } from "clsx";
/**
* Rückmeldung beim Tippen — geteilt von der Suche und der Bibliothek.
*
* Beide Filterleisten wissen über `useTransition()` längst, dass eine neue
* Antwort unterwegs ist. Die Trefferliste weiß es nicht: sie ist eine
* Server-Komponente und steht als `children` daneben. Diese schmale Klammer
* reicht das eine Bit vom Filter zur Liste durch.
*
* Bewusst zwei Kontexte: der Setter behält so über alle Renderdurchgänge
* dieselbe Identität und taugt als Abhängigkeit eines Effekts.
*/
const PendingContext = createContext(false);
const SetPendingContext = createContext<(value: boolean) => void>(() => {});
export function SearchPendingProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState(false);
return (
<SetPendingContext.Provider value={setPending}>
<PendingContext.Provider value={pending}>{children}</PendingContext.Provider>
</SetPendingContext.Provider>
);
}
/** Für die Filterleiste: meldet, ob gerade gesucht wird. */
export function useReportSearchPending() {
return useContext(SetPendingContext);
}
/**
* Dämpft die Treffer, solange die neue Antwort unterwegs ist.
*
* Die alten Treffer bleiben stehen — sie verschwinden zu lassen wäre ein
* Rückschritt, weil die Liste dann bei jedem Tastendruck springt. Gedämpft
* und nicht anklickbar ist aber sichtbar „nicht mehr aktuell".
*/
export function SearchResults({ children }: { children: ReactNode }) {
const pending = useContext(PendingContext);
return (
<div
aria-busy={pending}
className={clsx(
"transition-opacity duration-200 motion-reduce:transition-none",
pending && "pointer-events-none opacity-45",
)}
>
{children}
</div>
);
}
/** Kleiner Hinweis neben dem Eingabefeld, solange gesucht wird. */
export function SearchSpinner({ show }: { show: boolean }) {
if (!show) return null;
return (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
);
}
+16 -3
View File
@@ -16,7 +16,7 @@ import type { ActionResult } from "@/app/actions/library";
type Toast = {
id: number;
message: string;
tone: "info" | "error";
tone: "info" | "error" | "erfolg";
undo?: () => Promise<void> | void;
};
@@ -58,6 +58,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
show(result.message, {
undo: undo && ids.length > 0 ? () => void undo(ids) : undefined,
});
// Errungenschaften bekommen eine eigene Einblendung — sie sollen nicht
// in der Meldung über das Abhaken untergehen.
for (const name of result.achievements ?? []) {
show(`Errungenschaft: ${name}`, { tone: "erfolg" });
}
},
[show],
);
@@ -96,8 +102,15 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
borderColor:
toast.tone === "error"
? "color-mix(in oklab, var(--fl-danger) 50%, transparent)"
: "var(--fl-border)",
color: toast.tone === "error" ? "var(--fl-danger)" : "var(--fl-text)",
: toast.tone === "erfolg"
? "color-mix(in oklab, var(--fl-success) 50%, transparent)"
: "var(--fl-border)",
color:
toast.tone === "error"
? "var(--fl-danger)"
: toast.tone === "erfolg"
? "var(--fl-success)"
: "var(--fl-text)",
}}
>
<span className="flex-1">{toast.message}</span>
+23 -12
View File
@@ -63,7 +63,7 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
</div>
<div className="mt-2 space-y-1">
<p className="line-clamp-2 text-sm font-medium leading-snug">{item.title}</p>
<p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug">{item.title}</p>
<p className="text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
@@ -74,17 +74,28 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
: ""}
</p>
{showProgress && (
<div className="pt-0.5">
<ProgressBar
percent={item.percent}
label={`${item.watched} von ${item.total} Episoden gesehen`}
/>
<p className="mt-1 text-xs text-[var(--fl-faint)]">
{item.watched}/{item.total} Episoden
</p>
</div>
)}
<div className="pt-0.5">
{showProgress ? (
<>
<ProgressBar
percent={item.percent}
label={`${item.watched} von ${item.total} Episoden gesehen`}
/>
<p className="mt-1 text-xs text-[var(--fl-faint)]">
{item.watched}/{item.total} Episoden
</p>
</>
) : (
// Filme und Watchlist-Einträge haben keinen Balken. Der Platz wird
// trotzdem freigehalten — sonst fallen sie kürzer aus als Serien und
// das Raster franst reihenweise aus. Der Nachbau statt einer festen
// Pixelhöhe hält sich von selbst nach, wenn sich der Balken ändert.
<div className="invisible" aria-hidden>
<div className="fl-progress" />
<p className="mt-1 text-xs">&nbsp;</p>
</div>
)}
</div>
</div>
</Link>
);
+22 -10
View File
@@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx";
import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
import type { LibraryFacets } from "@/lib/queries";
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
/**
* Filterleiste der Bibliothek.
@@ -26,11 +27,19 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const [, startTransition] = useTransition();
const [pending, startTransition] = useTransition();
const [query, setQuery] = useState(params.get("q") ?? "");
const first = useRef(true);
// Der Trefferliste mitteilen, dass eine neue Antwort unterwegs ist — ohne
// das Signal blieben die alten Treffer stumm stehen und es sah aus, als
// passiere nichts.
const reportPending = useReportSearchPending();
useEffect(() => {
reportPending(pending);
}, [pending, reportPending]);
function apply(changes: Record<string, string | null>) {
const next = new URLSearchParams(params.toString());
for (const [key, value] of Object.entries(changes)) {
@@ -48,7 +57,7 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
first.current = false;
return;
}
const timer = setTimeout(() => apply({ q: query.trim() || null }), 300);
const timer = setTimeout(() => apply({ q: query.trim() || null }), 250);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
@@ -62,14 +71,17 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
return (
<div className="space-y-3">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="In der Bibliothek suchen …"
aria-label="Bibliothek durchsuchen"
className="fl-input"
/>
<div className="relative">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="In der Bibliothek suchen …"
aria-label="Bibliothek durchsuchen"
className="fl-input"
/>
<SearchSpinner show={pending} />
</div>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern">
<Chip active={!status} onClick={() => apply({ status: null })}>
+25 -3
View File
@@ -2,7 +2,12 @@
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { markMovieAction, undoAction, watchNextAction } from "@/app/actions/library";
import {
markMovieAction,
undoAction,
unmarkMovieAction,
watchNextAction,
} from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
@@ -93,14 +98,31 @@ export function WatchMovieButton({
router.refresh();
}
// Im Zustand „gesehen" bleibt die grüne Plakette stehen, ist aber klickbar —
// so wie sich Episodenhäkchen auch wieder abwählen lassen. Vorher stand hier
// ein <p>: ein Etikett ohne Klickziel, aus dem es kein Zurück gab.
if (watched) {
return (
<p className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
<button
type="button"
disabled={pending}
title="Markierung wieder entfernen"
onClick={() =>
startTransition(async () => {
report(await unmarkMovieAction(entryId));
router.refresh();
})
}
className="group inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)] transition-colors hover:border-[color-mix(in_oklab,var(--fl-success)_70%,transparent)] hover:bg-[color-mix(in_oklab,var(--fl-success)_20%,transparent)] disabled:opacity-60"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Gesehen
</p>
<span className="text-xs text-[color-mix(in_oklab,var(--fl-success)_75%,var(--fl-muted))]">
— Markierung entfernen
</span>
</button>
);
}
+18 -8
View File
@@ -100,6 +100,7 @@ export function ProfileForm({
email: string;
region: string;
publicProfile: boolean;
showAchievements: boolean;
profileSlug: string | null;
notifyNewEpisode: boolean;
notifyRelease: boolean;
@@ -121,6 +122,7 @@ export function ProfileForm({
name: state.name,
region: state.region,
publicProfile: state.publicProfile,
showAchievements: state.showAchievements,
profileSlug: state.profileSlug || null,
notifyNewEpisode: state.notifyNewEpisode,
notifyRelease: state.notifyRelease,
@@ -190,14 +192,22 @@ export function ProfileForm({
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
/>
{state.publicProfile && (
<Field
id="profileSlug"
label="Adresse"
value={state.profileSlug ?? ""}
onChange={(event) => setState({ ...state, profileSlug: event.target.value })}
placeholder="dein-name"
hint="Erreichbar unter /u/<adresse>"
/>
<>
<Field
id="profileSlug"
label="Adresse"
value={state.profileSlug ?? ""}
onChange={(event) => setState({ ...state, profileSlug: event.target.value })}
placeholder="dein-name"
hint="Erreichbar unter /u/<adresse>"
/>
<Toggle
checked={state.showAchievements}
onChange={(value) => setState({ ...state, showAchievements: value })}
label="Errungenschaften zeigen"
hint="Die erreichten Abzeichen erscheinen auf der Profilseite."
/>
</>
)}
</fieldset>
)}
+500
View File
@@ -0,0 +1,500 @@
"use client";
import { useState, useTransition } from "react";
import { clsx } from "clsx";
import { useRouter } from "next/navigation";
import {
connectMediaServerAction,
deleteMediaServerAction,
importMediaServerTitlesAction,
syncMediaServerAction,
testMediaServerAction,
updateMediaServerAction,
type MediaServerResult,
} from "@/app/actions/settings";
import { useToasts } from "@/components/Toasts";
import { formatDate } from "@/lib/labels";
/**
* Verbindung zu Jellyfin einrichten und abgleichen.
*
* Zwei Dinge stehen bewusst deutlich in der Oberfläche:
*
* - Der Verbindungstest sagt ausdrücklich, ob der **Gesehen-Status** ankommt.
* Mit einem reinen API-Key täte er das nicht — und der Abgleich liefe stumm
* ins Leere, ohne dass jemand wüsste, warum.
* - Das Aufnehmen fehlender Titel ist ein **Häkchen**, keine Selbstverständ-
* lichkeit. Eine Mediathek mit 800 Filmen soll nicht ungefragt zur
* Bibliothek werden.
*/
export type UnassignedItem = {
tmdbId: number | null;
typ: "SERIES" | "MOVIE" | null;
name: string;
grund: string;
dateien: number;
};
export type Verbindung = {
id: string;
name: string;
baseUrl: string;
serverUserName: string;
enabled: boolean;
syncOwned: boolean;
syncWatched: boolean;
lastSyncAt: string | null;
/** Aus dem letzten Abgleich — Grundlage der Auswahlliste. */
offen: UnassignedItem[];
weitereOffen: number;
};
type Pruefung = NonNullable<MediaServerResult["pruefung"]>;
type Bericht = NonNullable<MediaServerResult["bericht"]>;
export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung[] }) {
const router = useRouter();
const { show } = useToasts();
const [pending, startTransition] = useTransition();
const [baseUrl, setBaseUrl] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [aufnehmen, setAufnehmen] = useState(false);
const [pruefung, setPruefung] = useState<Pruefung | null>(null);
const [bericht, setBericht] = useState<Bericht | null>(null);
const [laeuft, setLaeuft] = useState(false);
async function verbinden() {
setLaeuft(true);
setPruefung(null);
try {
const ergebnis = await connectMediaServerAction({ baseUrl, username, password });
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
if (ergebnis.ok) {
setPruefung(ergebnis.pruefung ?? null);
setPassword("");
startTransition(() => router.refresh());
}
} finally {
setLaeuft(false);
}
}
async function testen(id: string) {
setLaeuft(true);
try {
const ergebnis = await testMediaServerAction(id);
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
setPruefung(ergebnis.pruefung ?? null);
} finally {
setLaeuft(false);
}
}
async function abgleichen(id: string) {
setLaeuft(true);
setBericht(null);
try {
const ergebnis = await syncMediaServerAction(id, aufnehmen);
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
setBericht(ergebnis.bericht ?? null);
startTransition(() => router.refresh());
} finally {
setLaeuft(false);
}
}
async function umschalten(id: string, data: Partial<Verbindung>) {
const ergebnis = await updateMediaServerAction(id, data);
if (!ergebnis.ok) show(ergebnis.message ?? "", { tone: "error" });
startTransition(() => router.refresh());
}
async function entfernen(id: string, name: string) {
const ergebnis = await deleteMediaServerAction(id);
show(ergebnis.ok ? `„${name}“ entfernt` : (ergebnis.message ?? ""), {
tone: ergebnis.ok ? "info" : "error",
});
startTransition(() => router.refresh());
}
return (
<div className="space-y-5">
{verbindungen.length > 0 && (
<ul className="space-y-3">
{verbindungen.map((verbindung) => (
<li key={verbindung.id} className="rounded-xl border border-[var(--fl-border)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{verbindung.name}</p>
<p className="text-xs text-[var(--fl-faint)]">
{verbindung.baseUrl} · angemeldet als {verbindung.serverUserName}
</p>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{verbindung.lastSyncAt
? `zuletzt abgeglichen ${formatDate(verbindung.lastSyncAt)}`
: "noch nicht abgeglichen"}
</p>
</div>
<button
type="button"
onClick={() => entfernen(verbindung.id, verbindung.name)}
disabled={pending || laeuft}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Entfernen
</button>
</div>
<div className="mt-3 flex flex-wrap gap-4 text-sm">
<Haken
label="Im täglichen Abgleich mitlaufen"
checked={verbindung.enabled}
onChange={(wert) => umschalten(verbindung.id, { enabled: wert })}
/>
<Haken
label="Bestand übernehmen"
checked={verbindung.syncOwned}
onChange={(wert) => umschalten(verbindung.id, { syncOwned: wert })}
/>
<Haken
label="Sichtungen übernehmen"
checked={verbindung.syncWatched}
onChange={(wert) => umschalten(verbindung.id, { syncWatched: wert })}
/>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => abgleichen(verbindung.id)}
disabled={pending || laeuft}
className="fl-btn fl-btn-primary"
>
{laeuft ? "Läuft …" : "Jetzt abgleichen"}
</button>
<button
type="button"
onClick={() => testen(verbindung.id)}
disabled={pending || laeuft}
className="fl-btn fl-btn-ghost"
>
Verbindung prüfen
</button>
<Haken
label="Bekannte Titel dabei in die Bibliothek aufnehmen"
checked={aufnehmen}
onChange={setAufnehmen}
/>
</div>
</li>
))}
</ul>
)}
{pruefung && <Pruefbericht pruefung={pruefung} />}
{bericht && <Abgleichbericht bericht={bericht} />}
{verbindungen.map((verbindung) =>
verbindung.offen.length > 0 ? (
<Nachladeliste
key={`offen-${verbindung.id}`}
verbindung={verbindung}
laeuft={laeuft || pending}
onImport={async (auswahl) => {
setLaeuft(true);
try {
const ergebnis = await importMediaServerTitlesAction(verbindung.id, auswahl);
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
startTransition(() => router.refresh());
} finally {
setLaeuft(false);
}
}}
/>
) : null,
)}
{verbindungen.length === 0 && (
<div className="space-y-3">
<p className="text-sm text-[var(--fl-muted)]">
Flimmer kann aus Jellyfin übernehmen, was du besitzt und was du dort schon gesehen
hast. Zugeordnet wird über die TMDB-Kennung, die Jellyfin mitliefert.
</p>
<div className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1 text-sm">
<span className="text-[var(--fl-muted)]">Adresse</span>
<input
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="http://jellyfin.fritz.box:8096"
className="fl-input py-1.5 text-sm"
/>
</label>
<label className="space-y-1 text-sm">
<span className="text-[var(--fl-muted)]">Benutzername</span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="off"
className="fl-input py-1.5 text-sm"
/>
</label>
<label className="space-y-1 text-sm">
<span className="text-[var(--fl-muted)]">Passwort</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
className="fl-input py-1.5 text-sm"
/>
</label>
</div>
<p className="text-xs text-[var(--fl-faint)]">
Bewusst Benutzername und Passwort statt eines API-Schlüssels: mit einem Schlüssel
liefert Jellyfin den Bestand ohne Gesehen-Status. Das Zugangstoken wird verschlüsselt
abgelegt, dein Passwort nicht gespeichert.
</p>
<button
type="button"
onClick={verbinden}
disabled={laeuft || !baseUrl || !username || !password}
className="fl-btn fl-btn-primary"
>
{laeuft ? "Verbinde …" : "Verbinden"}
</button>
</div>
)}
</div>
);
}
function Pruefbericht({ pruefung }: { pruefung: Pruefung }) {
return (
<div className="rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-2)] p-4 text-sm">
<p className="font-medium">
{pruefung.serverName}
{pruefung.version ? ` · Version ${pruefung.version}` : ""}
</p>
<p className="mt-1 text-[var(--fl-muted)]">
{pruefung.itemCount} Einträge in der Mediathek, davon {pruefung.withTmdbId} der ersten 50
mit TMDB-Kennung.
</p>
{pruefung.hasUserData ? (
<p className="mt-2 text-[var(--fl-success)]">✓ Der Gesehen-Status kommt mit.</p>
) : (
<p className="mt-2 text-[var(--fl-warning)]">
! Der Gesehen-Status fehlt. Das passiert, wenn die Anmeldung über einen API-Schlüssel
statt über ein Benutzerkonto läuft — dann kann Flimmer nur den Bestand übernehmen, keine
Sichtungen.
</p>
)}
</div>
);
}
function Abgleichbericht({ bericht }: { bericht: Bericht }) {
return (
<div className="rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-2)] p-4 text-sm">
<p className="font-medium">Bericht</p>
<ul className="mt-1 space-y-0.5 text-[var(--fl-muted)]">
<li>{bericht.gelesen} Einträge gelesen</li>
<li>{bericht.bestandGesetzt}× „hab ich“ gesetzt</li>
<li>{bericht.sichtungenAngelegt} Sichtungen übernommen</li>
{bericht.aufgenommen > 0 && <li>{bericht.aufgenommen} Titel neu aufgenommen</li>}
</ul>
{bericht.nichtZugeordnet.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-[var(--fl-muted)]">
{bericht.nichtZugeordnet.length} nicht zuzuordnen
</summary>
<ul className="mt-2 space-y-0.5 text-xs text-[var(--fl-faint)]">
{bericht.nichtZugeordnet.map((eintrag, index) => (
<li key={`${eintrag.name}-${index}`}>
{eintrag.name} — {eintrag.grund}
</li>
))}
</ul>
</details>
)}
{bericht.fehler.length > 0 && (
<ul className="mt-3 space-y-0.5 text-xs text-[var(--fl-danger)]">
{bericht.fehler.map((fehler, index) => (
<li key={index}>{fehler}</li>
))}
</ul>
)}
</div>
);
}
function Haken({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (wert: boolean) => void;
}) {
return (
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="h-4 w-4 rounded border-[var(--fl-border-strong)] accent-[var(--fl-accent)]"
/>
<span className="text-[var(--fl-muted)]">{label}</span>
</label>
);
}
/**
* Auswahlliste für Titel, die Flimmer noch nicht kennt.
*
* Der Unterschied, der in der Oberfläche sichtbar sein muss: Das Häkchen oben
* nimmt auf, was Flimmer **kennt**. Diese Liste holt, was es **nicht kennt** —
* und zwar nur das, was hier angehakt wird. Bei über tausend Einträgen in
* einer Mediathek wäre „alles oder nichts" keine Wahl.
*/
function Nachladeliste({
verbindung,
laeuft,
onImport,
}: {
verbindung: Verbindung;
laeuft: boolean;
onImport: (auswahl: { tmdbId: number; typ: "SERIES" | "MOVIE" }[]) => Promise<void>;
}) {
const [suche, setSuche] = useState("");
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set());
const gefiltert = verbindung.offen.filter((eintrag) =>
eintrag.name.toLocaleLowerCase("de-DE").includes(suche.trim().toLocaleLowerCase("de-DE")),
);
// Ohne TMDB-Kennung lässt sich nichts holen — die Zeilen bleiben sichtbar,
// aber nicht anklickbar. Sie zu verstecken würde die Frage aufwerfen, wo
// sie geblieben sind.
const holbar = gefiltert.filter((eintrag) => eintrag.tmdbId != null);
const ohneKennung = verbindung.offen.length - verbindung.offen.filter((e) => e.tmdbId != null).length;
function umschalten(tmdbId: number) {
setGewaehlt((vorher) => {
const neu = new Set(vorher);
if (neu.has(tmdbId)) neu.delete(tmdbId);
else neu.add(tmdbId);
return neu;
});
}
const alleGewaehlt = holbar.length > 0 && holbar.every((e) => gewaehlt.has(e.tmdbId!));
return (
<div className="rounded-xl border border-[var(--fl-border)] p-4">
<h3 className="font-medium">Noch nicht in Flimmer</h3>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{verbindung.offen.length} Titel liegen auf {verbindung.name}, sind Flimmer aber unbekannt.
Wähle aus, was übernommen werden soll — die Daten kommen dann von TMDB.
{verbindung.weitereOffen > 0 && ` (${verbindung.weitereOffen} weitere nicht aufgeführt)`}
</p>
{ohneKennung > 0 && (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Bei {ohneKennung} davon fehlt die TMDB-Kennung in Jellyfin selbst — die lassen sich nicht
automatisch holen. Meist hilft es, die Erkennung in Jellyfin nachzuziehen.
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
type="search"
value={suche}
onChange={(event) => setSuche(event.target.value)}
placeholder="In der Liste suchen …"
aria-label="Liste durchsuchen"
className="fl-input w-56 py-1.5 text-sm"
/>
<button
type="button"
onClick={() =>
setGewaehlt(alleGewaehlt ? new Set() : new Set(holbar.map((e) => e.tmdbId!)))
}
disabled={holbar.length === 0}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
{alleGewaehlt ? "Auswahl aufheben" : `Alle ${holbar.length} auswählen`}
</button>
</div>
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto pr-1">
{gefiltert.map((eintrag) => {
const holbarEintrag = eintrag.tmdbId != null;
return (
<li key={eintrag.tmdbId ?? `name-${eintrag.name}`}>
<label
className={clsx(
"flex items-start gap-2 rounded-lg px-2 py-1.5 text-sm",
holbarEintrag ? "hover:bg-[var(--fl-surface-2)]" : "opacity-60",
)}
>
<input
type="checkbox"
disabled={!holbarEintrag}
checked={holbarEintrag && gewaehlt.has(eintrag.tmdbId!)}
onChange={() => holbarEintrag && umschalten(eintrag.tmdbId!)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-[var(--fl-border-strong)] accent-[var(--fl-accent)]"
/>
<span className="min-w-0">
<span className="block truncate">{eintrag.name}</span>
<span className="block text-xs text-[var(--fl-faint)]">
{holbarEintrag
? `${eintrag.typ === "SERIES" ? "Serie" : "Film"} · TMDB ${eintrag.tmdbId}`
: "ohne TMDB-Kennung"}
{eintrag.dateien > 1 && ` · ${eintrag.dateien} Dateien`}
</span>
</span>
</label>
</li>
);
})}
{gefiltert.length === 0 && (
<li className="px-2 py-1.5 text-sm text-[var(--fl-faint)]">Nichts gefunden.</li>
)}
</ul>
<button
type="button"
disabled={laeuft || gewaehlt.size === 0}
onClick={() =>
onImport(
verbindung.offen
.filter((eintrag) => eintrag.tmdbId != null && gewaehlt.has(eintrag.tmdbId))
.map((eintrag) => ({ tmdbId: eintrag.tmdbId!, typ: eintrag.typ ?? "MOVIE" })),
)
}
className="fl-btn fl-btn-primary mt-3"
>
{laeuft
? "Läuft …"
: gewaehlt.size === 0
? "Nichts ausgewählt"
: `${gewaehlt.size} ${gewaehlt.size === 1 ? "Titel" : "Titel"} übernehmen`}
</button>
{gewaehlt.size > 100 && (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Es werden 100 auf einmal geholt; der Rest bleibt ausgewählt für den nächsten Lauf.
</p>
)}
</div>
);
}
+278
View File
@@ -0,0 +1,278 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
startAuthentication,
startRegistration,
WebAuthnError,
} from "@simplewebauthn/browser";
import { useToasts } from "@/components/Toasts";
import { formatDate } from "@/lib/labels";
/**
* Passkeys anlegen und verwalten.
*
* Der Browser übernimmt den heiklen Teil; hier laufen nur zwei Anfragen —
* Optionen holen, Antwort einreichen. Fehler des Authenticators tragen
* kryptische Namen, deshalb werden die häufigen übersetzt.
*/
export type PasskeyEintrag = {
id: string;
name: string;
deviceType: string;
backedUp: boolean;
createdAt: string;
lastUsedAt: string | null;
};
/** Übersetzt die Fehler des Browsers in Sätze, mit denen man etwas anfangen kann. */
export function passkeyFehlertext(error: unknown): string | null {
if (error instanceof WebAuthnError) {
switch (error.name) {
case "InvalidStateError":
return "Dieses Gerät ist hier schon hinterlegt.";
case "NotAllowedError":
// Auch der Abbruch durch den Nutzer landet hier — dann ist Schweigen
// die richtige Antwort.
return null;
case "SecurityError":
return "Passkeys brauchen HTTPS oder localhost. Über diese Adresse geht es nicht.";
default:
return "Der Passkey konnte nicht verwendet werden.";
}
}
if (error instanceof DOMException && error.name === "NotAllowedError") return null;
return "Das hat nicht geklappt.";
}
export function PasskeyList({
passkeys,
available,
hinweis,
}: {
passkeys: PasskeyEintrag[];
available: boolean;
hinweis: string | null;
}) {
const router = useRouter();
const { show } = useToasts();
const [pending, startTransition] = useTransition();
const [name, setName] = useState("");
async function anlegen() {
try {
const antwort = await fetch("/api/auth/passkey/registrierung/start", { method: "POST" });
const optionen = await antwort.json();
if (!antwort.ok) {
show(optionen.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
const ergebnis = await startRegistration({ optionsJSON: optionen });
const fertig = await fetch("/api/auth/passkey/registrierung/abschluss", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: name.trim() || geraeteVorschlag(), response: ergebnis }),
});
const daten = await fertig.json();
if (!fertig.ok) {
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
setName("");
show("Passkey angelegt");
startTransition(() => router.refresh());
} catch (error) {
const text = passkeyFehlertext(error);
if (text) show(text, { tone: "error" });
}
}
async function umbenennen(id: string, alt: string, neu: string) {
const name = neu.trim();
if (!name || name === alt) return;
const antwort = await fetch(`/api/passkeys/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name }),
});
if (!antwort.ok) {
const daten = await antwort.json().catch(() => ({}));
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
show("Umbenannt");
startTransition(() => router.refresh());
}
async function entfernen(id: string, wie: string) {
const antwort = await fetch(`/api/passkeys/${id}`, { method: "DELETE" });
const daten = await antwort.json().catch(() => ({}));
if (!antwort.ok) {
show(daten.error ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
show(`„${wie}" entfernt`);
startTransition(() => router.refresh());
}
if (!available) {
return (
<p className="rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
{hinweis}
</p>
);
}
return (
<div className="space-y-4">
{passkeys.length > 0 ? (
<ul className="divide-y divide-[var(--fl-border)]">
{passkeys.map((passkey) => (
<li key={passkey.id} className="flex flex-wrap items-center gap-3 py-2.5">
<div className="min-w-0 flex-1">
{/* Der Name ist direkt überschreibbar — ein eigener
Bearbeiten-Modus wäre für ein einzelnes Feld zu viel. */}
<input
defaultValue={passkey.name}
aria-label={`Name von „${passkey.name}"`}
maxLength={60}
onBlur={(event) => umbenennen(passkey.id, passkey.name, event.target.value)}
className="w-full truncate rounded-md border border-transparent bg-transparent px-1 py-0.5 font-medium transition-colors hover:border-[var(--fl-border)] focus:border-[var(--fl-border-strong)] focus:outline-none"
/>
<p className="px-1 text-xs text-[var(--fl-faint)]">
{passkey.backedUp ? "synchronisiert" : "nur auf diesem Gerät"}
{" · angelegt "}
{formatDate(passkey.createdAt)}
{passkey.lastUsedAt
? ` · zuletzt benutzt ${formatDate(passkey.lastUsedAt)}`
: " · noch nicht benutzt"}
</p>
</div>
<button
type="button"
disabled={pending}
onClick={() => entfernen(passkey.id, passkey.name)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Entfernen
</button>
</li>
))}
</ul>
) : (
<p className="text-sm text-[var(--fl-muted)]">
Noch kein Passkey hinterlegt. Mit einem kannst du dich ohne Passwort anmelden — per
Fingerabdruck, Gesicht oder Sicherheitsschlüssel.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={geraeteVorschlag()}
aria-label="Name für den neuen Passkey"
maxLength={60}
className="fl-input w-56 py-1.5 text-sm"
/>
<button type="button" onClick={anlegen} disabled={pending} className="fl-btn fl-btn-primary">
Passkey anlegen
</button>
</div>
{passkeys.some((passkey) => !passkey.backedUp) && (
<p className="text-xs text-[var(--fl-faint)]">
Ein Passkey, der nur auf einem Gerät liegt, ist mit dem Gerät verloren. Halte fürs
Erste einen zweiten Anmeldeweg bereit.
</p>
)}
</div>
);
}
/** Ein brauchbarer Vorschlag, damit niemand „Passkey 1" tippen muss. */
function geraeteVorschlag(): string {
if (typeof navigator === "undefined") return "Mein Gerät";
const ua = navigator.userAgent;
if (/iPhone/.test(ua)) return "iPhone";
if (/iPad/.test(ua)) return "iPad";
if (/Android/.test(ua)) return "Android-Gerät";
if (/Mac OS X/.test(ua)) return "Mac";
if (/Windows/.test(ua)) return "Windows-PC";
if (/Linux/.test(ua)) return "Linux-Rechner";
return "Mein Gerät";
}
/** Knopf auf der Anmeldeseite. */
export function PasskeyLoginButton({ label }: { label: string }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
async function anmelden() {
setFehler(null);
setPending(true);
try {
const antwort = await fetch("/api/auth/passkey/anmeldung/start", { method: "POST" });
const optionen = await antwort.json();
if (!antwort.ok) {
setFehler(optionen.error ?? "Das hat nicht geklappt.");
return;
}
const ergebnis = await startAuthentication({ optionsJSON: optionen });
const fertig = await fetch("/api/auth/passkey/anmeldung/abschluss", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(ergebnis),
});
const daten = await fertig.json();
if (!fertig.ok) {
setFehler(daten.error ?? "Das hat nicht geklappt.");
return;
}
router.replace("/");
router.refresh();
} catch (error) {
setFehler(passkeyFehlertext(error));
} finally {
setPending(false);
}
}
return (
<div className="space-y-2">
<button
type="button"
onClick={anmelden}
disabled={pending}
className="fl-btn fl-btn-ghost w-full justify-center gap-2"
>
<KeyIcon />
{pending ? "Einen Moment …" : label}
</button>
{fehler && (
<p className="text-sm text-[var(--fl-danger)]" role="alert">
{fehler}
</p>
)}
</div>
);
}
function KeyIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="8" cy="12" r="3.5" />
<path d="M11.5 12H21m-3 0v3m-3-3v2" strokeLinecap="round" />
</svg>
);
}
@@ -9,6 +9,7 @@ import {
createOrderAction,
createUniverseAction,
deleteOrderAction,
setDefaultOrderAction,
shareUniverseAction,
trashUniverseAction,
unshareUniverseAction,
@@ -109,6 +110,24 @@ export function OrderTabs({
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{order.name}
{order.isDefault && (
<>
{/* Häkchen an der Reihenfolge, die beim Öffnen erscheint. Das
Wort dahinter steht nur für Screenreader — sichtbar würde
es die Reiter auseinanderziehen. */}
<svg
viewBox="0 0 24 24"
className="ml-1.5 inline h-3.5 w-3.5 align-[-2px]"
fill="none"
stroke="currentColor"
strokeWidth="3"
aria-hidden
>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="sr-only"> (Standard)</span>
</>
)}
</button>
);
})}
@@ -158,6 +177,21 @@ export function OrderTabs({
</button>
)}
{active && !active.isDefault && (
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await setDefaultOrderAction(active.id));
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
Als Standard festlegen
</button>
)}
{active && active.kind === "CUSTOM" && (
<button
type="button"
+69
View File
@@ -0,0 +1,69 @@
/**
* Eigennamen für bekannte Filmreihen.
*
* Die Grundlage ist generisch: „Reihe komplett — Spider-Man" funktioniert für
* jede Reihe, die TMDB kennt, und braucht keine Pflege. Diese Datei setzt
* obendrauf: wo ein Eintrag passt, bekommt das Abzeichen einen Namen mit
* Augenzwinkern. Fehlt er, greift der generische — nichts hängt davon ab.
*
* Die Schlüssel sind **TMDB-Collection-Kennungen**, nachzuschlagen unter
* `themoviedb.org/collection/<id>`.
*
* ## Warum jeder Eintrag ein `erwartet` hat
*
* Eine falsch abgeschriebene Kennung wäre schlimmer als gar kein Eintrag: Wer
* die Ocean's-Reihe durchhat, bekäme „Spider Bro". Deshalb trägt jeder Eintrag
* ein Stück des Namens, den TMDB für diese Reihe führt. Passt es nicht zum
* tatsächlichen Namen, wird der Eintrag ignoriert und der generische Name
* greift. Ein Tippfehler bleibt damit folgenlos.
*
* Erweitern ist ausdrücklich erwünscht — eine Zeile hier, sonst nichts.
*/
import type { SymbolName } from "./achievements";
export type KuratierteReihe = {
name: string;
symbol: SymbolName;
/** Muss im TMDB-Namen der Reihe vorkommen, sonst gilt der Eintrag nicht. */
erwartet: string;
};
const REIHEN: Record<number, KuratierteReihe> = {
556: { name: "Spider Bro", symbol: "spinne", erwartet: "Spider-Man" },
531241: { name: "Spider Bro", symbol: "spinne", erwartet: "Spider-Man" },
119: { name: "Ringträger", symbol: "kreis", erwartet: "Herr der Ringe" },
121938: { name: "Auf unerwarteter Reise", symbol: "berg", erwartet: "Hobbit" },
10: { name: "Möge die Macht", symbol: "stern", erwartet: "Star Wars" },
264: { name: "Wo wir hinfahren", symbol: "sanduhr", erwartet: "Zurück in die Zukunft" },
2344: { name: "Aufgewacht", symbol: "kreis", erwartet: "Matrix" },
84: { name: "Gehört ins Museum", symbol: "kompass", erwartet: "Indiana Jones" },
8091: { name: "Im All hört dich niemand", symbol: "planet", erwartet: "Alien" },
328: { name: "Leben findet einen Weg", symbol: "spross", erwartet: "Jurassic Park" },
295: { name: "Freibeuter", symbol: "kompass", erwartet: "Fluch der Karibik" },
1241: { name: "Ganze sieben Jahre", symbol: "schluessel", erwartet: "Harry Potter" },
528: { name: "Ich komme wieder", symbol: "kreis", erwartet: "Terminator" },
230: { name: "Ein Angebot", symbol: "maske", erwartet: "Pate" },
10194: { name: "Bis zur Unendlichkeit", symbol: "planet", erwartet: "Toy Story" },
2150: { name: "Aus dem Sumpf", symbol: "spross", erwartet: "Shrek" },
};
/**
* Der kuratierte Eintrag — aber nur, wenn er zum tatsächlichen Namen passt.
*
* `tmdbName` ist der Name, den TMDB für diese Reihe liefert. Fehlt er, lässt
* sich nichts prüfen; dann gilt der Eintrag im Zweifel nicht.
*/
export function kuratierteReihe(
tmdbCollectionId: number,
tmdbName: string | null | undefined,
): KuratierteReihe | undefined {
const eintrag = REIHEN[tmdbCollectionId];
if (!eintrag || !tmdbName) return undefined;
const passt = tmdbName.toLocaleLowerCase("de-DE").includes(eintrag.erwartet.toLocaleLowerCase("de-DE"));
return passt ? eintrag : undefined;
}
/** Nur für Tests und Übersichten. */
export const ALLE_KURATIERTEN_REIHEN = REIHEN;
+668
View File
@@ -0,0 +1,668 @@
import "server-only";
import { prisma } from "./db";
import { kuratierteReihe } from "./achievement-names";
/**
* Errungenschaften.
*
* Grundhaltung: **still belohnen, nicht antreiben.** Keine Streaks, die einen
* bestrafen, wenn eine Woche nichts läuft. Keine Fortschrittsbalken, die
* drängeln. Ein Abzeichen taucht auf, wenn etwas erreicht ist — und bleibt.
*
* Zwei Entscheidungen, die das Verhalten prägen:
*
* - **Einmal erreicht, bleibt erreicht.** Wer eine Sichtung zurücknimmt,
* verliert kein Abzeichen. Es wieder einzukassieren wäre kleinlich, und die
* Datenlage ändert sich schon durch einen Fehlklick.
* - **Alle Regeln arbeiten auf *einem* Datensatz**, der einmal geholt wird.
* Sonst stellt jede Regel ihre eigene Datenbankabfrage, und aus zwanzig
* Abzeichen werden zwanzig Abfragen bei jedem Abhaken.
*/
export type AchievementKey = string;
/** Ein Abzeichen, wie es die Oberfläche braucht. */
export type AchievementView = {
key: string;
name: string;
beschreibung: string;
symbol: SymbolName;
gruppe: Gruppe;
unlockedAt: Date | null;
};
export type Gruppe = "anfaenge" | "reihen" | "ausdauer" | "breite" | "ordnung";
export const GRUPPEN: { key: Gruppe; label: string }[] = [
{ key: "anfaenge", label: "Anfänge" },
{ key: "reihen", label: "Reihen und Universen" },
{ key: "ausdauer", label: "Ausdauer" },
{ key: "breite", label: "Breite" },
{ key: "ordnung", label: "Ordnung" },
];
export type SymbolName =
| "spross"
| "haken"
| "schluessel"
| "haus"
| "klappe"
| "planet"
| "kompass"
| "bildschirm"
| "filmrolle"
| "sonne"
| "kalender"
| "laeufer"
| "berg"
| "herz"
| "kreis"
| "maske"
| "sanduhr"
| "stern"
| "salz"
| "scheibe"
| "besen"
| "mond"
| "spinne";
// ---------------------------------------------------------------------------
// Der Datensatz, auf dem alle Regeln arbeiten
// ---------------------------------------------------------------------------
type Datensatz = {
/** Zeitzone des Nutzers — „nachts um vier" ist seine Uhrzeit, nicht die des Servers. */
zeitzone: string;
eintraege: number;
minutenGesamt: number;
episodenGesehen: number;
filmeGesehen: number;
bewertungen: number;
niedrigsteBewertung: number | null;
besitzt: number;
watchlist: number;
serienAbgeschlossen: { titleId: string; episoden: number }[];
hoechsterDurchlauf: number;
genres: Set<string>;
jahrzehnte: Set<number>;
/** Größter Abstand zwischen erster und letzter Sichtung derselben Serie, in Tagen. */
laengsteTreue: number;
/** Meiste Minuten an einem Kalendertag (Zeitzone des Nutzers). */
minutenBesterTag: number;
/** Gab es eine Sichtung zwischen 3 und 5 Uhr? */
nachtsGesehen: boolean;
hatPasskey: boolean;
hatMedienserverLauf: boolean;
/** Vollständig gesehene TMDB-Reihen: Kennung → Name der Reihe. */
reihen: Map<number, string>;
/** Vollständig abgeschlossene Universen: Kennung → Name. */
universen: Map<string, string>;
/** Universen, die über eine eigene Reihenfolge abgeschlossen wurden. */
eigeneReihenfolge: boolean;
};
async function sammle(userId: string): Promise<Datensatz> {
// Zeitzone: Flimmer speichert sie nicht je Nutzer, also gilt die der Instanz
// (TZ in der .env). An einer Stelle, damit ein späterer Wechsel auf eine
// Zeitzone je Konto nur hier ansetzen muss.
const zeitzone = process.env.TZ || "Europe/Berlin";
const [eintraege, watches, serien, universen] = await Promise.all([
prisma.libraryEntry.findMany({
where: { userId, deletedAt: null },
select: {
id: true,
titleId: true,
status: true,
rating: true,
owned: true,
currentRun: true,
title: { select: { type: true, genres: true, releaseDate: true, tmdbCollectionId: true } },
},
}),
prisma.watch.findMany({
where: { userId },
select: { titleId: true, episodeId: true, watchedAt: true, minutes: true },
orderBy: { watchedAt: "asc" },
}),
prisma.title.findMany({
where: { entries: { some: { userId, deletedAt: null } }, type: "SERIES" },
select: { id: true, episodeCount: true, title: true, tmdbCollectionId: true },
}),
prisma.universe.findMany({
where: { ownerId: userId, deletedAt: null },
select: {
id: true,
name: true,
items: { select: { titleId: true } },
orders: { select: { kind: true, isDefault: true } },
},
}),
]);
const [passkeys, medienserver] = await Promise.all([
prisma.passkey.count({ where: { userId } }),
prisma.mediaServerConnection.count({ where: { userId, lastSyncAt: { not: null } } }),
]);
// --- Aus den Sichtungen ableiten ---
const minutenGesamt = watches.reduce((summe, watch) => summe + watch.minutes, 0);
const episodenGesehen = watches.filter((watch) => watch.episodeId !== null).length;
const filmeGesehen = watches.filter((watch) => watch.episodeId === null).length;
const proTag = new Map<string, number>();
const spanneProTitel = new Map<string, { erste: Date; letzte: Date }>();
let nachtsGesehen = false;
for (const watch of watches) {
const tag = tagIn(watch.watchedAt, zeitzone);
proTag.set(tag, (proTag.get(tag) ?? 0) + watch.minutes);
const stunde = stundeIn(watch.watchedAt, zeitzone);
if (stunde >= 3 && stunde < 5) nachtsGesehen = true;
const spanne = spanneProTitel.get(watch.titleId);
if (spanne) spanne.letzte = watch.watchedAt;
else spanneProTitel.set(watch.titleId, { erste: watch.watchedAt, letzte: watch.watchedAt });
}
const minutenBesterTag = Math.max(0, ...proTag.values());
const laengsteTreue = Math.max(
0,
...[...spanneProTitel.values()].map(
(spanne) => (spanne.letzte.getTime() - spanne.erste.getTime()) / 86_400_000,
),
);
// --- Aus den Einträgen ableiten ---
const genres = new Set<string>();
const jahrzehnte = new Set<number>();
const gesehenTitel = new Set(watches.map((watch) => watch.titleId));
let bewertungen = 0;
let niedrigsteBewertung: number | null = null;
let besitzt = 0;
let watchlist = 0;
let hoechsterDurchlauf = 0;
for (const eintrag of eintraege) {
if (eintrag.rating != null) {
bewertungen += 1;
niedrigsteBewertung =
niedrigsteBewertung == null ? eintrag.rating : Math.min(niedrigsteBewertung, eintrag.rating);
}
if (eintrag.owned) besitzt += 1;
if (eintrag.status === "WATCHLIST") watchlist += 1;
hoechsterDurchlauf = Math.max(hoechsterDurchlauf, eintrag.currentRun);
// Genres und Jahrzehnte zählen nur, was tatsächlich gesehen wurde.
if (gesehenTitel.has(eintrag.titleId)) {
for (const genre of eintrag.title.genres) genres.add(genre);
if (eintrag.title.releaseDate) {
jahrzehnte.add(Math.floor(eintrag.title.releaseDate.getFullYear() / 10) * 10);
}
}
}
// --- Abgeschlossene Serien ---
const episodenProSerie = new Map<string, number>();
for (const watch of watches) {
if (watch.episodeId === null) continue;
episodenProSerie.set(watch.titleId, (episodenProSerie.get(watch.titleId) ?? 0) + 1);
}
const serienAbgeschlossen = serien
.filter(
(serie) =>
serie.episodeCount > 0 && (episodenProSerie.get(serie.id) ?? 0) >= serie.episodeCount,
)
.map((serie) => ({ titleId: serie.id, episoden: serie.episodeCount }));
// --- Vollständig gesehene TMDB-Reihen ---
// Eine Reihe zählt, wenn alle Filme der Reihe, die im Katalog stehen,
// gesehen sind — und es mindestens zwei sind. Ein einzelner Film ist keine
// Reihe.
const reihen = new Map<number, string>();
const reihenIds = [
...new Set(
eintraege
.map((eintrag) => eintrag.title.tmdbCollectionId)
.filter((id): id is number => id != null),
),
];
if (reihenIds.length > 0) {
const reihenTitel = await prisma.title.findMany({
where: { tmdbCollectionId: { in: reihenIds } },
select: { id: true, tmdbCollectionId: true, tmdbCollectionName: true },
});
const proReihe = new Map<number, { id: string; name: string | null }[]>();
for (const titel of reihenTitel) {
const liste = proReihe.get(titel.tmdbCollectionId!) ?? [];
liste.push({ id: titel.id, name: titel.tmdbCollectionName });
proReihe.set(titel.tmdbCollectionId!, liste);
}
for (const [id, titel] of proReihe) {
if (titel.length < 2) continue;
if (!titel.every((eintrag) => gesehenTitel.has(eintrag.id))) continue;
reihen.set(id, titel.find((eintrag) => eintrag.name)?.name ?? "Reihe");
}
}
// --- Abgeschlossene Universen ---
const abgeschlossene = new Map<string, string>();
let eigeneReihenfolge = false;
for (const universum of universen) {
if (universum.items.length === 0) continue;
if (!universum.items.every((item) => gesehenTitel.has(item.titleId))) continue;
abgeschlossene.set(universum.id, universum.name);
// „Chronologe": abgeschlossen, und die führende Reihenfolge ist eine
// selbst gebaute — nicht die berechnete Erscheinungsreihenfolge.
if (universum.orders.some((order) => order.kind === "CUSTOM" && order.isDefault)) {
eigeneReihenfolge = true;
}
}
return {
zeitzone,
eintraege: eintraege.length,
minutenGesamt,
episodenGesehen,
filmeGesehen,
bewertungen,
niedrigsteBewertung,
besitzt,
watchlist,
serienAbgeschlossen,
hoechsterDurchlauf,
genres,
jahrzehnte,
laengsteTreue,
minutenBesterTag,
nachtsGesehen,
hatPasskey: passkeys > 0,
hatMedienserverLauf: medienserver > 0,
reihen,
universen: abgeschlossene,
eigeneReihenfolge,
};
}
/**
* Kalendertag und Stunde in der Zeitzone des Nutzers.
*
* Wichtig genug für eigene Helfer: beim Monatsdiagramm hat ein
* Zeitzonenversatz schon einmal für einen leeren Chart gesorgt. „Acht Stunden
* an einem Tag" und „nachts um vier" wären in der Serverzeitzone schlicht
* falsch.
*/
function tagIn(zeitpunkt: Date, zeitzone: string): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: zeitzone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(zeitpunkt);
}
function stundeIn(zeitpunkt: Date, zeitzone: string): number {
// Über die Bestandteile statt über die formatierte Zeichenkette: auf Deutsch
// liefert Intl für eine einzelne Stunde „03 Uhr", und daraus wird beim
// Umwandeln in eine Zahl NaN. Ein Test hat genau das aufgedeckt.
const teile = new Intl.DateTimeFormat("de-DE", {
timeZone: zeitzone,
hour: "2-digit",
hour12: false,
}).formatToParts(zeitpunkt);
const stunde = teile.find((teil) => teil.type === "hour")?.value ?? "";
const zahl = Number.parseInt(stunde, 10);
return Number.isFinite(zahl) ? zahl : -1;
}
// ---------------------------------------------------------------------------
// Der Katalog
// ---------------------------------------------------------------------------
type Regel = {
key: string;
name: string;
beschreibung: string;
symbol: SymbolName;
gruppe: Gruppe;
erfuellt: (daten: Datensatz) => boolean;
};
const KATALOG: Regel[] = [
// --- Anfänge ---
{
key: "erstkontakt",
name: "Erstkontakt",
beschreibung: "Den ersten Titel in die Bibliothek aufgenommen",
symbol: "spross",
gruppe: "anfaenge",
erfuellt: (d) => d.eintraege >= 1,
},
{
key: "abgehakt",
name: "Abgehakt",
beschreibung: "Die erste Serie vollständig durchgeschaut",
symbol: "haken",
gruppe: "anfaenge",
erfuellt: (d) => d.serienAbgeschlossen.length >= 1,
},
{
key: "schluesselmeister",
name: "Schlüsselmeister",
beschreibung: "Einen Passkey angelegt",
symbol: "schluessel",
gruppe: "anfaenge",
erfuellt: (d) => d.hatPasskey,
},
{
key: "heimkehrer",
name: "Heimkehrer",
beschreibung: "Den eigenen Medienserver angebunden und abgeglichen",
symbol: "haus",
gruppe: "anfaenge",
erfuellt: (d) => d.hatMedienserverLauf,
},
// --- Reihen und Universen (dynamische kommen weiter unten dazu) ---
{
key: "chronologe",
name: "Chronologe",
beschreibung: "Ein Universum in einer selbst gebauten Reihenfolge durchlaufen",
symbol: "kompass",
gruppe: "reihen",
erfuellt: (d) => d.eigeneReihenfolge,
},
// --- Ausdauer ---
{
key: "serientaeter-1000",
name: "Serientäter",
beschreibung: "1000 Episoden gesehen",
symbol: "bildschirm",
gruppe: "ausdauer",
erfuellt: (d) => d.episodenGesehen >= 1000,
},
{
key: "vielseher-100",
name: "Vielseher",
beschreibung: "100 Filme gesehen",
symbol: "filmrolle",
gruppe: "ausdauer",
erfuellt: (d) => d.filmeGesehen >= 100,
},
{
key: "ein-ganzer-tag",
name: "Ein ganzer Tag",
beschreibung: "24 Stunden Schauzeit insgesamt",
symbol: "sonne",
gruppe: "ausdauer",
erfuellt: (d) => d.minutenGesamt >= 24 * 60,
},
{
key: "eine-ganze-woche",
name: "Eine ganze Woche",
beschreibung: "168 Stunden Schauzeit insgesamt",
symbol: "kalender",
gruppe: "ausdauer",
erfuellt: (d) => d.minutenGesamt >= 7 * 24 * 60,
},
{
key: "marathon",
name: "Marathon",
beschreibung: "Acht Stunden an einem Tag",
symbol: "laeufer",
gruppe: "ausdauer",
erfuellt: (d) => d.minutenBesterTag >= 8 * 60,
},
{
key: "langer-atem",
name: "Der lange Atem",
beschreibung: "Eine Serie mit mindestens 100 Episoden komplett gesehen",
symbol: "berg",
gruppe: "ausdauer",
erfuellt: (d) => d.serienAbgeschlossen.some((serie) => serie.episoden >= 100),
},
{
key: "treu-geblieben",
name: "Treu geblieben",
beschreibung: "Über ein Jahr an demselben Titel drangeblieben",
symbol: "herz",
gruppe: "ausdauer",
erfuellt: (d) => d.laengsteTreue >= 365,
},
{
key: "wiederholungstaeter",
name: "Wiederholungstäter",
beschreibung: "Einen Titel im dritten Durchlauf",
symbol: "kreis",
gruppe: "ausdauer",
erfuellt: (d) => d.hoechsterDurchlauf >= 2,
},
// --- Breite ---
{
key: "feinschmecker",
name: "Feinschmecker",
beschreibung: "Titel aus zehn verschiedenen Genres gesehen",
symbol: "maske",
gruppe: "breite",
erfuellt: (d) => d.genres.size >= 10,
},
{
key: "zeitreisender",
name: "Zeitreisender",
beschreibung: "Titel aus sieben Jahrzehnten gesehen",
symbol: "sanduhr",
gruppe: "breite",
erfuellt: (d) => d.jahrzehnte.size >= 7,
},
{
key: "kritiker-50",
name: "Kritiker",
beschreibung: "50 Bewertungen abgegeben",
symbol: "stern",
gruppe: "breite",
erfuellt: (d) => d.bewertungen >= 50,
},
{
key: "strenges-urteil",
name: "Strenges Urteil",
beschreibung: "Einmal die Note 1 vergeben",
symbol: "salz",
gruppe: "breite",
erfuellt: (d) => d.niedrigsteBewertung === 1,
},
// --- Ordnung ---
{
key: "sammler-25",
name: "Sammler",
beschreibung: "25 Titel in der eigenen Sammlung",
symbol: "scheibe",
gruppe: "ordnung",
erfuellt: (d) => d.besitzt >= 25,
},
{
key: "aufgeraeumt",
name: "Aufgeräumt",
beschreibung: "Die Watchlist vollständig abgearbeitet — bei mindestens 20 Titeln",
symbol: "besen",
gruppe: "ordnung",
// Nur sinnvoll, wenn überhaupt etwas da war: die Bibliothek muss die 20
// hergeben, die Watchlist aber leer sein.
erfuellt: (d) => d.watchlist === 0 && d.eintraege >= 20,
},
{
key: "durchgemacht",
name: "Durchgemacht",
beschreibung: "Zwischen drei und fünf Uhr morgens etwas abgehakt",
symbol: "mond",
gruppe: "ordnung",
erfuellt: (d) => d.nachtsGesehen,
},
];
/** Alle Abzeichen, die dieser Datensatz hergibt — feste und dynamische. */
function erreichte(daten: Datensatz): { key: string; label?: string }[] {
const treffer: { key: string; label?: string }[] = KATALOG.filter((regel) =>
regel.erfuellt(daten),
).map((regel) => ({ key: regel.key }));
// Reihen: je vollständig gesehener TMDB-Reihe ein eigenes Abzeichen. Der
// Name wird eingefroren, damit er später nicht wandert.
// Eingefroren wird der **TMDB-Name** der Reihe, nicht der Anzeigename. Der
// kuratierte Name wird erst beim Anzeigen aufgesetzt — so lässt er sich
// später ändern, ohne gespeicherte Abzeichen anzufassen.
for (const [id, name] of daten.reihen) {
treffer.push({ key: `reihe:${id}`, label: name });
}
for (const [id, name] of daten.universen) {
treffer.push({ key: `universum:${id}`, label: name });
}
return treffer;
}
// ---------------------------------------------------------------------------
// Auswerten
// ---------------------------------------------------------------------------
export type Ergebnis = {
/** Neu vergebene Abzeichen — leer, wenn sich nichts geändert hat. */
neu: { key: string; label: string | null }[];
/** Beim allerersten Lauf eines Kontos wird nichts gemeldet. */
stumm: boolean;
};
/**
* Wertet aus und vergibt, was fehlt.
*
* Der **erste Lauf eines Kontos ist stumm**: bestehende Bibliotheken bringen
* Jahre an Daten mit, und zwei Dutzend Benachrichtigungen auf einmal wären
* keine Freude, sondern Lärm.
*/
export async function evaluateAchievements(userId: string): Promise<Ergebnis> {
const [daten, vorhanden] = await Promise.all([
sammle(userId),
prisma.achievement.findMany({ where: { userId }, select: { key: true } }),
]);
const bekannt = new Set(vorhanden.map((eintrag) => eintrag.key));
const erster = vorhanden.length === 0;
const fehlend = erreichte(daten).filter((eintrag) => !bekannt.has(eintrag.key));
if (fehlend.length === 0) return { neu: [], stumm: erster };
await prisma.achievement.createMany({
data: fehlend.map((eintrag) => ({ userId, key: eintrag.key, label: eintrag.label ?? null })),
skipDuplicates: true,
});
return {
neu: erster ? [] : fehlend.map((eintrag) => ({ key: eintrag.key, label: eintrag.label ?? null })),
stumm: erster,
};
}
/**
* Wertet aus, falls für dieses Konto noch nie ausgewertet wurde.
*
* Gedacht für den ersten Aufruf der Übersicht: ohne diesen Anstoß bliebe sie
* bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt. Ein
* Konto durchläuft das genau einmal — danach kostet es eine Zählabfrage.
*/
export async function ensureAchievementsEvaluated(userId: string): Promise<void> {
const vorhanden = await prisma.achievement.count({ where: { userId } });
if (vorhanden > 0) return;
await evaluateAchievements(userId);
}
/** Für den täglichen Lauf: alle Konten durchgehen. */
export async function evaluateAllAchievements(): Promise<{ konten: number; vergeben: number }> {
const konten = await prisma.user.findMany({ where: { isActive: true }, select: { id: true } });
let vergeben = 0;
for (const konto of konten) {
try {
const ergebnis = await evaluateAchievements(konto.id);
vergeben += ergebnis.neu.length;
} catch (error) {
console.error("Errungenschaften konnten nicht ausgewertet werden:", error);
}
}
return { konten: konten.length, vergeben };
}
// ---------------------------------------------------------------------------
// Anzeigen
// ---------------------------------------------------------------------------
/**
* Alles für die Übersicht: erreichte Abzeichen mit Datum, dazu die offenen
* mit ihrer Bedingung. Die offenen zu zeigen ist Absicht — sie sind die
* Landkarte, nicht die Peitsche.
*/
export async function listAchievements(userId: string): Promise<AchievementView[]> {
const vergeben = await prisma.achievement.findMany({
where: { userId },
select: { key: true, label: true, unlockedAt: true },
orderBy: { unlockedAt: "desc" },
});
const nachKey = new Map(vergeben.map((eintrag) => [eintrag.key, eintrag]));
const feste: AchievementView[] = KATALOG.map((regel) => ({
key: regel.key,
name: regel.name,
beschreibung: regel.beschreibung,
symbol: regel.symbol,
gruppe: regel.gruppe,
unlockedAt: nachKey.get(regel.key)?.unlockedAt ?? null,
}));
// Dynamische gibt es nur, wenn sie erreicht wurden — eine Liste aller
// denkbaren Filmreihen wäre sinnlos lang.
const dynamisch: AchievementView[] = vergeben
.filter((eintrag) => eintrag.key.includes(":"))
.map((eintrag) => {
const istReihe = eintrag.key.startsWith("reihe:");
const id = Number(eintrag.key.slice("reihe:".length));
const kuratiert = istReihe ? kuratierteReihe(id, eintrag.label) : undefined;
return {
key: eintrag.key,
name: kuratiert?.name ?? eintrag.label ?? (istReihe ? "Reihe komplett" : "Universum vollendet"),
beschreibung: istReihe
? `Alle Filme der Reihe „${eintrag.label ?? "…"}" gesehen`
: `Alle Titel des Universums „${eintrag.label ?? "…"}" abgeschlossen`,
symbol: (kuratiert?.symbol ?? (istReihe ? "klappe" : "planet")) as SymbolName,
gruppe: "reihen" as Gruppe,
unlockedAt: eintrag.unlockedAt,
};
});
return [...feste, ...dynamisch];
}
/**
* Anzeigename eines Abzeichens aus Schlüssel und eingefrorenem Namen.
* Wird für die Einblendung direkt nach dem Erreichen gebraucht, wo noch keine
* vollständige Liste geholt wurde.
*/
export function achievementName(key: string, label: string | null): string {
if (key.startsWith("reihe:")) {
const id = Number(key.slice("reihe:".length));
return kuratierteReihe(id, label)?.name ?? `Reihe komplett — ${label ?? "…"}`;
}
if (key.startsWith("universum:")) return `Universum vollendet — ${label ?? "…"}`;
return KATALOG.find((regel) => regel.key === key)?.name ?? key;
}
/** Nur die erreichten — für das öffentliche Profil. */
export async function listUnlockedAchievements(userId: string): Promise<AchievementView[]> {
return (await listAchievements(userId)).filter((abzeichen) => abzeichen.unlockedAt !== null);
}
+35
View File
@@ -81,6 +81,41 @@ export const env = {
},
},
passkeys: {
/**
* Passkeys lassen sich abschalten wie OIDC. Standardmäßig an — sie
* kosten nichts, solange niemand einen anlegt.
*/
enabled: bool("ENABLE_PASSKEYS", true),
/**
* Die RP ID ist der Hostname, an den ein Passkey gebunden ist. Sie kommt
* aus APP_URL und **nicht** aus dem Host-Header: hinter einem Reverse
* Proxy hinge sie sonst am falschen Namen, und alle Passkeys wären
* plötzlich ungültig.
*/
get rpId(): string {
try {
return new URL(env.appUrl).hostname;
} catch {
return "localhost";
}
},
/**
* Der Browser verlangt einen sicheren Kontext. Erlaubt sind HTTPS und
* localhost; eine nackte IP-Adresse ist als RP ID ohnehin unzulässig.
*/
get isAvailable(): boolean {
if (!this.enabled) return false;
try {
const url = new URL(env.appUrl);
if (url.protocol === "https:") return true;
return url.hostname === "localhost" || url.hostname.endsWith(".localhost");
} catch {
return false;
}
},
},
oidc: {
issuer: optional("OIDC_ISSUER"),
clientId: optional("OIDC_CLIENT_ID"),
+248
View File
@@ -0,0 +1,248 @@
import "server-only";
import { env } from "./env";
/**
* Client für Jellyfin (und Emby — die API ist an den hier benutzten Stellen
* dieselbe).
*
* Zwei Dinge, die beim Bauen Zeit gekostet hätten, wenn man sie nicht vorher
* weiß:
*
* 1. **Mit einem reinen API-Key fehlt `UserData`.** Man bekommt den Bestand,
* aber ohne Gesehen-Status — und ohne Fehlermeldung. Deshalb meldet sich
* Flimmer über `AuthenticateByName` an und prüft beim Verbinden
* ausdrücklich nach, ob `UserData` ankommt.
* 2. Jellyfin erwartet einen `Authorization`-Kopf in einer eigenen Form
* (`MediaBrowser Client=…`), nicht `Bearer`.
*/
export class JellyfinError extends Error {}
/** Ein Item, so weit Flimmer es braucht. */
export type JellyfinItem = {
Id: string;
Name: string;
Type: "Movie" | "Episode" | "Series" | string;
ProviderIds?: Record<string, string> | null;
/** Bei Episoden: die Serie darüber. */
SeriesId?: string | null;
SeriesName?: string | null;
/** Staffel- und Episodennummer. */
ParentIndexNumber?: number | null;
IndexNumber?: number | null;
ProductionYear?: number | null;
RunTimeTicks?: number | null;
UserData?: {
Played?: boolean;
PlayCount?: number;
LastPlayedDate?: string | null;
} | null;
};
export type JellyfinUser = { Id: string; Name: string };
type ClientOptions = { baseUrl: string; token: string };
/** Der Kopf, den Jellyfin zur Identifikation erwartet. */
function authHeader(token?: string): string {
const teile = [
'Client="Flimmer"',
`Device="${env.appName.replace(/"/g, "")}"`,
'DeviceId="flimmer-sync"',
'Version="1.0"',
];
if (token) teile.push(`Token="${token}"`);
return `MediaBrowser ${teile.join(", ")}`;
}
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, "");
}
async function request<T>(
{ baseUrl, token }: ClientOptions,
pfad: string,
init: RequestInit = {},
): Promise<T> {
const url = `${normalizeBaseUrl(baseUrl)}${pfad}`;
let antwort: Response;
try {
antwort = await fetch(url, {
...init,
cache: "no-store",
headers: {
accept: "application/json",
authorization: authHeader(token),
...(init.body ? { "content-type": "application/json" } : {}),
...init.headers,
},
// Der Bestand kann groß sein und der Server steht oft im selben Netz —
// trotzdem nicht ewig warten.
signal: AbortSignal.timeout(60_000),
});
} catch (error) {
const grund = error instanceof Error ? error.message : "unbekannt";
throw new JellyfinError(`Der Server ist nicht erreichbar (${grund}).`);
}
if (antwort.status === 401) {
throw new JellyfinError("Die Anmeldedaten wurden abgelehnt.");
}
if (!antwort.ok) {
throw new JellyfinError(`Der Server antwortete mit ${antwort.status}.`);
}
if (antwort.status === 204) return undefined as T;
return (await antwort.json()) as T;
}
/**
* Anmelden mit Benutzername und Passwort.
*
* Bewusst nicht mit einem API-Key: der liefert keine `UserData` und damit
* keinen Gesehen-Status.
*/
export async function authenticate(
baseUrl: string,
username: string,
password: string,
): Promise<{ token: string; user: JellyfinUser; serverName: string }> {
const antwort = await request<{
AccessToken: string;
User: JellyfinUser;
ServerId?: string;
}>(
{ baseUrl, token: "" },
"/Users/AuthenticateByName",
{
method: "POST",
body: JSON.stringify({ Username: username, Pw: password }),
},
);
if (!antwort.AccessToken) throw new JellyfinError("Der Server hat kein Zugangstoken geliefert.");
const info = await systemInfo(baseUrl, antwort.AccessToken).catch(() => null);
return {
token: antwort.AccessToken,
user: antwort.User,
serverName: info?.ServerName ?? "Jellyfin",
};
}
export async function systemInfo(baseUrl: string, token: string) {
return request<{ ServerName?: string; Version?: string }>(
{ baseUrl, token },
"/System/Info",
);
}
/**
* Bestand und Sichtungen eines Nutzers.
*
* `enableUserData=true` ist der entscheidende Parameter — ohne ihn (oder mit
* einem API-Key statt eines Nutzertokens) fehlt der Gesehen-Status.
*/
export async function fetchItems(
baseUrl: string,
token: string,
serverUserId: string,
{ startIndex = 0, limit = 500 }: { startIndex?: number; limit?: number } = {},
): Promise<{ items: JellyfinItem[]; total: number }> {
const params = new URLSearchParams({
userId: serverUserId,
Recursive: "true",
IncludeItemTypes: "Movie,Episode",
Fields: "ProviderIds,ParentId",
enableUserData: "true",
EnableImages: "false",
StartIndex: String(startIndex),
Limit: String(limit),
SortBy: "Id",
SortOrder: "Ascending",
});
const antwort = await request<{ Items?: JellyfinItem[]; TotalRecordCount?: number }>(
{ baseUrl, token },
`/Items?${params.toString()}`,
);
return { items: antwort.Items ?? [], total: antwort.TotalRecordCount ?? 0 };
}
/** Kopfdaten einer Serie — dort hängt die TMDB-ID, nicht an der Episode. */
export async function fetchSeries(
baseUrl: string,
token: string,
serverUserId: string,
seriesIds: string[],
): Promise<Map<string, JellyfinItem>> {
const treffer = new Map<string, JellyfinItem>();
if (seriesIds.length === 0) return treffer;
// In Blöcken abfragen: eine Adresse mit hunderten Kennungen wird sonst zu lang.
for (let start = 0; start < seriesIds.length; start += 50) {
const block = seriesIds.slice(start, start + 50);
const params = new URLSearchParams({
userId: serverUserId,
Ids: block.join(","),
Fields: "ProviderIds",
EnableImages: "false",
});
const antwort = await request<{ Items?: JellyfinItem[] }>(
{ baseUrl, token },
`/Items?${params.toString()}`,
);
for (const item of antwort.Items ?? []) treffer.set(item.Id, item);
}
return treffer;
}
/**
* Prüft die Verbindung — und ob der Gesehen-Status tatsächlich mitkommt.
*
* Genau hier liegt der Fallstrick: mit einem API-Key liefert Jellyfin den
* Bestand ohne `UserData`. Ohne diese Prüfung liefe der Abgleich stumm ins
* Leere und niemand wüsste, warum nichts als gesehen ankommt.
*/
export async function checkConnection(
baseUrl: string,
token: string,
serverUserId: string,
): Promise<{
serverName: string;
version: string | null;
itemCount: number;
hasUserData: boolean;
withTmdbId: number;
}> {
const info = await systemInfo(baseUrl, token);
const { items, total } = await fetchItems(baseUrl, token, serverUserId, { limit: 50 });
return {
serverName: info.ServerName ?? "Jellyfin",
version: info.Version ?? null,
itemCount: total,
hasUserData: items.length === 0 || items.some((item) => item.UserData != null),
withTmdbId: items.filter((item) => tmdbIdOf(item) != null).length,
};
}
/** Die TMDB-ID aus den ProviderIds — Jellyfin schreibt den Schlüssel uneinheitlich. */
export function tmdbIdOf(item: JellyfinItem): number | null {
const ids = item.ProviderIds ?? {};
const roh = ids.Tmdb ?? ids.TMDB ?? ids.tmdb ?? ids.TmdbId;
if (!roh) return null;
const zahl = Number.parseInt(String(roh), 10);
return Number.isFinite(zahl) && zahl > 0 ? zahl : null;
}
/** Läuft der Server unter dieser Adresse überhaupt? */
export async function listUsers(baseUrl: string, token: string): Promise<JellyfinUser[]> {
return request<JellyfinUser[]>({ baseUrl, token }, "/Users");
}
+21
View File
@@ -338,6 +338,27 @@ export async function markMovieWatched(
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" };
}
/**
* Nimmt die Sichtung eines Films im aktuellen Durchlauf zurück.
*
* Das Gegenstück zu `markMovieWatched()` — und das Pendant zu
* `unmarkEpisodes()` bei Serien.
*
* Die Eingrenzung auf `run` ist wesentlich: wer einen Film dreimal gesehen
* hat, hat drei Sichtungen. Ohne `run` löschte das Zurücknehmen alle drei und
* verfälschte rückwirkend die Statistik. Zurückgenommen wird nur, was im
* laufenden Durchlauf abgehakt wurde.
*/
export async function unmarkMovie(userId: string, entryId: string) {
const entry = await requireEntry(userId, entryId);
if (entry.title.type !== "MOVIE") throw new LibraryError("Das ist keine Filmsichtung.");
await prisma.watch.deleteMany({
where: { userId, entryId, episodeId: null, run: entry.currentRun },
});
return reconcileStatus(userId, entryId);
}
/** Macht eine Sichtungsaktion rückgängig. */
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
await requireEntry(userId, entryId);
+642
View File
@@ -0,0 +1,642 @@
import "server-only";
import { prisma } from "./db";
import { env } from "./env";
import { ensureTitle } from "./catalog";
import { decryptSecret, encryptSecret } from "./secrets";
import {
authenticate,
checkConnection,
fetchItems,
fetchSeries,
JellyfinError,
tmdbIdOf,
type JellyfinItem,
} from "./jellyfin";
/**
* Abgleich mit einem Medienserver.
*
* Grundhaltung: **Einbahnstraße.** Der Abgleich ergänzt, was auf dem Server
* liegt oder dort gelaufen ist — er löscht nie etwas, das in Flimmer steht.
* Wer einen Film in Flimmer als gesehen markiert und ihn dann in Jellyfin
* löscht, verliert seine Sichtung nicht.
*
* Zugeordnet wird über die **TMDB-ID**, die Jellyfin in `ProviderIds`
* mitliefert — dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut.
* Eine unscharfe Suche über Titel und Jahr ist damit nicht nötig, und die
* Zuordnung ist entweder eindeutig oder sie findet nicht statt.
*
* **Grenze der Quelle:** Jellyfin kennt `PlayCount` und `LastPlayedDate`, aber
* keine Liste einzelner Sichtungen. Rewatch-Durchläufe lassen sich daraus
* nicht rekonstruieren — übernommen wird der letzte Termin. Ein dreimal
* geschauter Film erscheint in der Statistik mit einem Termin, nicht mit drei.
*/
export class MediaServerError extends Error {}
/**
* Ein Titel, den der Abgleich nicht zuordnen konnte.
*
* Zusammengefasst je TMDB-Kennung: Jellyfin führt oft mehrere Dateien zum
* selben Film (Extended Edition, zweite Fassung), und in einer Auswahlliste
* will niemand denselben Titel dreimal anhaken.
*/
export type UnassignedItem = {
/** Fehlt sie, lässt sich der Titel nicht automatisch holen. */
tmdbId: number | null;
typ: "SERIES" | "MOVIE" | null;
name: string;
grund: string;
/** Wie viele Jellyfin-Dateien auf diesen Titel zeigen. */
dateien: number;
};
export type SyncReport = {
gelesen: number;
bestandGesetzt: number;
sichtungenAngelegt: number;
aufgenommen: number;
/** Aus TMDB nachgeladen — nur über die Auswahl, nie von selbst. */
nachgeladen: number;
nichtZugeordnet: UnassignedItem[];
/** Wurde die Liste gekürzt? Dann steht hier, wie viele fehlen. */
weitereNichtZugeordnet: number;
fehler: string[];
};
const LEERER_BERICHT = (): SyncReport => ({
gelesen: 0,
bestandGesetzt: 0,
sichtungenAngelegt: 0,
aufgenommen: 0,
nachgeladen: 0,
nichtZugeordnet: [],
weitereNichtZugeordnet: 0,
fehler: [],
});
/**
* Obergrenze der Auswahlliste.
*
* Früher standen hier 50 — gedacht als Schutz vor Lärm, tatsächlich ein Blick
* durchs Schlüsselloch: bei über tausend gelesenen Einträgen sah man den
* Bruchteil. Jetzt großzügig, aber nicht unbegrenzt: ein Server, der
* Unsinn liefert, soll den Bericht nicht ins Unermessliche treiben. Was
* darüber hinausgeht, wird gezählt und benannt.
*/
const BERICHT_GRENZE = 1000;
/** Wie viele Titel ein Importlauf höchstens holt. */
const IMPORT_GRENZE = 100;
// ---------------------------------------------------------------------------
// Verbindung einrichten
// ---------------------------------------------------------------------------
export async function connectJellyfin(
userId: string,
input: { baseUrl: string; username: string; password: string },
) {
const baseUrl = input.baseUrl.trim().replace(/\/+$/, "");
if (!/^https?:\/\//i.test(baseUrl)) {
throw new MediaServerError("Die Adresse muss mit http:// oder https:// beginnen.");
}
let angemeldet;
try {
angemeldet = await authenticate(baseUrl, input.username, input.password);
} catch (error) {
throw new MediaServerError(
error instanceof JellyfinError ? error.message : "Die Anmeldung ist fehlgeschlagen.",
);
}
const pruefung = await checkConnection(baseUrl, angemeldet.token, angemeldet.user.Id).catch(
(error) => {
throw new MediaServerError(
error instanceof JellyfinError ? error.message : "Der Server antwortet nicht wie erwartet.",
);
},
);
const verbindung = await prisma.mediaServerConnection.upsert({
where: { userId_baseUrl: { userId, baseUrl } },
create: {
userId,
baseUrl,
kind: "JELLYFIN",
name: angemeldet.serverName,
token: encryptSecret(angemeldet.token),
serverUserId: angemeldet.user.Id,
serverUserName: angemeldet.user.Name,
},
update: {
name: angemeldet.serverName,
token: encryptSecret(angemeldet.token),
serverUserId: angemeldet.user.Id,
serverUserName: angemeldet.user.Name,
enabled: true,
},
select: { id: true, name: true, baseUrl: true, serverUserName: true },
});
return { verbindung, pruefung };
}
export async function listConnections(userId: string) {
return prisma.mediaServerConnection.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: {
id: true,
kind: true,
name: true,
baseUrl: true,
serverUserName: true,
enabled: true,
syncOwned: true,
syncWatched: true,
lastSyncAt: true,
lastSyncReport: true,
},
});
}
export async function updateConnection(
userId: string,
connectionId: string,
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
) {
const treffer = await prisma.mediaServerConnection.updateMany({
where: { id: connectionId, userId },
data,
});
if (treffer.count === 0) throw new MediaServerError("Diese Verbindung gibt es nicht.");
}
export async function deleteConnection(userId: string, connectionId: string) {
const treffer = await prisma.mediaServerConnection.deleteMany({
where: { id: connectionId, userId },
});
if (treffer.count === 0) throw new MediaServerError("Diese Verbindung gibt es nicht.");
}
/** Prüft eine bestehende Verbindung erneut — etwa nach einem Serverumzug. */
export async function testConnection(userId: string, connectionId: string) {
const verbindung = await requireConnection(userId, connectionId);
try {
return await checkConnection(
verbindung.baseUrl,
decryptSecret(verbindung.token),
verbindung.serverUserId,
);
} catch (error) {
throw new MediaServerError(
error instanceof JellyfinError ? error.message : "Der Server antwortet nicht wie erwartet.",
);
}
}
async function requireConnection(userId: string, connectionId: string) {
const verbindung = await prisma.mediaServerConnection.findFirst({
where: { id: connectionId, userId },
});
if (!verbindung) throw new MediaServerError("Diese Verbindung gibt es nicht.");
return verbindung;
}
// ---------------------------------------------------------------------------
// Abgleich
// ---------------------------------------------------------------------------
export async function syncConnection(
userId: string,
connectionId: string,
{ aufnehmen = false }: { aufnehmen?: boolean } = {},
): Promise<SyncReport> {
const verbindung = await requireConnection(userId, connectionId);
const bericht = LEERER_BERICHT();
const token = decryptSecret(verbindung.token);
const items = await alleItems(verbindung.baseUrl, token, verbindung.serverUserId);
bericht.gelesen = items.length;
// Episoden tragen die TMDB-ID nicht selbst — die hängt an der Serie darüber.
const serienIds = [...new Set(items.map((item) => item.SeriesId).filter((id): id is string => Boolean(id)))];
const serien = await fetchSeries(verbindung.baseUrl, token, verbindung.serverUserId, serienIds);
// Alles einmal vorladen statt je Item zu fragen.
const tmdbIds = new Set<number>();
for (const item of items) {
const id = item.Type === "Episode" ? tmdbIdOfEpisode(item, serien) : tmdbIdOf(item);
if (id != null) tmdbIds.add(id);
}
const titel = await prisma.title.findMany({
where: { tmdbId: { in: [...tmdbIds] } },
select: { id: true, tmdbId: true, type: true, title: true, runtime: true },
});
const titelNachTmdb = new Map(titel.map((eintrag) => [eintrag.tmdbId!, eintrag]));
const eintraege = await prisma.libraryEntry.findMany({
where: { userId, deletedAt: null },
select: { id: true, titleId: true, currentRun: true, owned: true },
});
const eintragNachTitel = new Map(eintraege.map((eintrag) => [eintrag.titleId, eintrag]));
// Zusammengefasst je Titel, nicht je Datei.
const gesammelt = new Map<string, UnassignedItem>();
const bestandTitelIds = new Set<string>();
const gesehen: { titleId: string; episodeNumber: number | null; seasonNumber: number | null; wann: Date; externalId: string }[] = [];
for (const item of items) {
const istEpisode = item.Type === "Episode";
const tmdbId = istEpisode ? tmdbIdOfEpisode(item, serien) : tmdbIdOf(item);
if (tmdbId == null) {
merkeUnzugeordnet(bericht, gesammelt, item, null, "keine TMDB-Kennung am Item");
continue;
}
const treffer = titelNachTmdb.get(tmdbId);
if (!treffer) {
merkeUnzugeordnet(bericht, gesammelt, item, tmdbId, "noch nicht im Katalog");
continue;
}
bestandTitelIds.add(treffer.id);
if (item.UserData?.Played) {
gesehen.push({
titleId: treffer.id,
seasonNumber: istEpisode ? (item.ParentIndexNumber ?? null) : null,
episodeNumber: istEpisode ? (item.IndexNumber ?? null) : null,
wann: item.UserData.LastPlayedDate ? new Date(item.UserData.LastPlayedDate) : new Date(),
externalId: item.Id,
});
}
}
// --- Bestand ---
if (verbindung.syncOwned) {
for (const titleId of bestandTitelIds) {
const vorhanden = eintragNachTitel.get(titleId);
if (!vorhanden) {
if (!aufnehmen) continue;
const neu = await prisma.libraryEntry.create({
data: { userId, titleId, status: "WATCHLIST", owned: true, ownedSource: "Jellyfin" },
select: { id: true, titleId: true, currentRun: true, owned: true },
});
eintragNachTitel.set(titleId, neu);
bericht.aufgenommen += 1;
bericht.bestandGesetzt += 1;
continue;
}
if (!vorhanden.owned) {
await prisma.libraryEntry.update({
where: { id: vorhanden.id },
data: { owned: true, ownedSource: "Jellyfin" },
});
bericht.bestandGesetzt += 1;
}
}
}
// --- Sichtungen ---
if (verbindung.syncWatched) {
bericht.sichtungenAngelegt = await uebernehmeSichtungen(
userId,
connectionId,
gesehen,
eintragNachTitel,
titelNachTmdb,
);
}
// Namen zuerst, damit die Auswahlliste durchsuchbar wirkt statt zufällig.
bericht.nichtZugeordnet = [...gesammelt.values()].sort((a, b) =>
a.name.localeCompare(b.name, "de-DE"),
);
await prisma.mediaServerConnection.update({
where: { id: connectionId },
data: { lastSyncAt: new Date(), lastSyncReport: bericht as object },
});
return bericht;
}
/** Blockweise lesen — eine große Mediathek passt nicht in eine Antwort. */
async function alleItems(baseUrl: string, token: string, serverUserId: string) {
const alle: JellyfinItem[] = [];
let startIndex = 0;
// Obergrenze als Reißleine: bei einem unerwarteten Serververhalten soll die
// Schleife enden, statt den Speicher zu füllen.
for (let runde = 0; runde < 200; runde += 1) {
const { items, total } = await fetchItems(baseUrl, token, serverUserId, {
startIndex,
limit: 500,
});
alle.push(...items);
startIndex += items.length;
if (items.length === 0 || alle.length >= total) break;
}
return alle;
}
function tmdbIdOfEpisode(item: JellyfinItem, serien: Map<string, JellyfinItem>): number | null {
// Manche Bestände tragen die Serien-ID auch an der Episode; sonst über die Serie.
const direkt = tmdbIdOf(item);
if (direkt != null) return direkt;
const serie = item.SeriesId ? serien.get(item.SeriesId) : undefined;
return serie ? tmdbIdOf(serie) : null;
}
/**
* Merkt sich einen nicht zuzuordnenden Titel — zusammengefasst.
*
* Der Schlüssel ist die TMDB-Kennung, sonst der Name. Jellyfin führt oft
* mehrere Dateien zum selben Film; in der Auswahlliste soll er einmal stehen,
* mit dem Vermerk, wie viele Dateien darauf zeigen.
*/
function merkeUnzugeordnet(
bericht: SyncReport,
gesammelt: Map<string, UnassignedItem>,
item: JellyfinItem,
tmdbId: number | null,
grund: string,
) {
// Bei Episoden zählt die Serie: einzelne Folgen einer unbekannten Serie
// ergeben keine sinnvolle Auswahl, die Serie schon.
const istEpisode = item.Type === "Episode";
// Der Typ kommt aus dem Item, nicht aus dem Umkehrschluss „keine Episode,
// also Film". Jellyfin liefert bei dieser Abfrage zwar nur Movie und
// Episode, aber ein Server, der auch Series mitschickt, bekäme sonst
// „Film" ans Ohr geheftet.
const istSerie = istEpisode || item.Type === "Series";
const name = istEpisode ? (item.SeriesName ?? item.Name ?? "Ohne Titel") : (item.Name ?? "Ohne Titel");
const schluessel = tmdbId != null ? `tmdb:${tmdbId}` : `name:${name.toLocaleLowerCase("de-DE")}`;
const vorhanden = gesammelt.get(schluessel);
if (vorhanden) {
vorhanden.dateien += 1;
return;
}
if (gesammelt.size >= BERICHT_GRENZE) {
bericht.weitereNichtZugeordnet += 1;
return;
}
gesammelt.set(schluessel, {
tmdbId,
typ: tmdbId == null ? null : istSerie ? "SERIES" : "MOVIE",
name,
grund,
dateien: 1,
});
}
/**
* Legt Sichtungen an, die es noch nicht gibt.
*
* Zwei Arten von Dubletten müssen verhindert werden:
*
* - **Episoden** über `(userId, episodeId, run)` — dafür gibt es den
* eindeutigen Index, geprüft wird trotzdem vorher, damit kein Lauf an einem
* Konflikt scheitert.
* - **Filme** haben `episodeId = null`, und Postgres behandelt NULL in
* eindeutigen Indizes als jeweils verschieden. Der Index greift dort also
* nicht — es wird ausdrücklich nachgesehen.
*/
async function uebernehmeSichtungen(
userId: string,
connectionId: string,
gesehen: { titleId: string; seasonNumber: number | null; episodeNumber: number | null; wann: Date; externalId: string }[],
eintragNachTitel: Map<string, { id: string; titleId: string; currentRun: number; owned: boolean }>,
titelNachTmdb: Map<number, { id: string; type: string; runtime: number | null }>,
): Promise<number> {
if (gesehen.length === 0) return 0;
const laufzeitNachTitel = new Map(
[...titelNachTmdb.values()].map((titel) => [titel.id, titel.runtime ?? 0]),
);
// Episoden auflösen: Staffel- und Episodennummer → interne Episoden-ID.
const episodenSchluessel = gesehen.filter((eintrag) => eintrag.episodeNumber != null);
const episoden = await prisma.episode.findMany({
where: {
titleId: { in: [...new Set(episodenSchluessel.map((eintrag) => eintrag.titleId))] },
},
select: { id: true, titleId: true, seasonNumber: true, episodeNumber: true, runtime: true },
});
const episodeNachSchluessel = new Map(
episoden.map((episode) => [
`${episode.titleId}:${episode.seasonNumber}:${episode.episodeNumber}`,
episode,
]),
);
let angelegt = 0;
for (const eintrag of gesehen) {
const bibliothek = eintragNachTitel.get(eintrag.titleId);
// Ohne Bibliothekseintrag keine Sichtung — sonst entstünden Sichtungen zu
// Titeln, die in Flimmer gar nicht geführt werden.
if (!bibliothek) continue;
const run = bibliothek.currentRun;
if (eintrag.episodeNumber != null) {
const episode = episodeNachSchluessel.get(
`${eintrag.titleId}:${eintrag.seasonNumber}:${eintrag.episodeNumber}`,
);
if (!episode) continue;
const schonDa = await prisma.watch.findFirst({
where: { userId, episodeId: episode.id, run },
select: { id: true },
});
if (schonDa) continue;
await prisma.watch.create({
data: {
userId,
entryId: bibliothek.id,
titleId: eintrag.titleId,
episodeId: episode.id,
run,
watchedAt: eintrag.wann,
minutes: episode.runtime ?? laufzeitNachTitel.get(eintrag.titleId) ?? 0,
source: "JELLYFIN",
},
});
angelegt += 1;
} else {
const schonDa = await prisma.watch.findFirst({
where: { userId, entryId: bibliothek.id, episodeId: null, run },
select: { id: true },
});
if (schonDa) continue;
await prisma.watch.create({
data: {
userId,
entryId: bibliothek.id,
titleId: eintrag.titleId,
run,
watchedAt: eintrag.wann,
minutes: laufzeitNachTitel.get(eintrag.titleId) ?? 0,
source: "JELLYFIN",
},
});
angelegt += 1;
}
await prisma.mediaServerItem.upsert({
where: { connectionId_externalId: { connectionId, externalId: eintrag.externalId } },
create: { connectionId, externalId: eintrag.externalId, titleId: eintrag.titleId },
update: {},
});
}
return angelegt;
}
/**
* Holt ausgewählte Titel aus TMDB nach und nimmt sie in die Bibliothek auf.
*
* Bewusst getrennt vom Abgleich: der läuft nachts und soll nie ungefragt
* hunderte Titel importieren. Das hier passiert nur auf Klick, mit einer
* Auswahl, die jemand getroffen hat.
*
* Ein einzelner Fehlschlag beendet den Lauf nicht — TMDB kennt nicht jede
* Kennung, die in einer Mediathek steht, und ein unbekannter Film soll die
* anderen neunundneunzig nicht mitreißen.
*/
export async function importUnassigned(
userId: string,
connectionId: string,
auswahl: { tmdbId: number; typ: "SERIES" | "MOVIE" }[],
): Promise<{ nachgeladen: number; fehler: string[]; uebrig: number }> {
await requireConnection(userId, connectionId);
if (!env.tmdb.isConfigured) {
throw new MediaServerError(
"Ohne TMDB-Schlüssel lassen sich keine Titel nachladen. Trage TMDB_API_KEY in der .env ein.",
);
}
// Obergrenze je Lauf: 100 Serien bedeuten mit Staffeln und Episoden schnell
// mehrere hundert TMDB-Abrufe. Der Rest kommt beim nächsten Mal.
const stapel = auswahl.slice(0, IMPORT_GRENZE);
const uebrig = Math.max(0, auswahl.length - stapel.length);
const fehler: string[] = [];
const geholt: number[] = [];
for (const eintrag of stapel) {
try {
const titel = await ensureTitle(eintrag.typ, eintrag.tmdbId, { region: env.tmdb.region });
// Aufnehmen und gleich als vorhanden markieren — er liegt ja auf dem
// Server. Ein bestehender Eintrag wird dabei nicht überschrieben.
const vorhanden = await prisma.libraryEntry.findUnique({
where: { userId_titleId: { userId, titleId: titel.id } },
select: { id: true, owned: true },
});
if (!vorhanden) {
await prisma.libraryEntry.create({
data: { userId, titleId: titel.id, status: "WATCHLIST", owned: true, ownedSource: "Jellyfin" },
});
} else if (!vorhanden.owned) {
await prisma.libraryEntry.update({
where: { id: vorhanden.id },
data: { owned: true, ownedSource: "Jellyfin" },
});
}
geholt.push(eintrag.tmdbId);
} catch (error) {
fehler.push(
`TMDB ${eintrag.tmdbId}: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
);
}
}
// Nur was tatsächlich ankam, verschwindet aus der Auswahlliste. Ein
// Fehlschlag — TMDB kurz nicht erreichbar, Kennung unbekannt — soll den
// Eintrag stehen lassen, damit man es noch einmal versuchen kann, ohne
// vorher den ganzen Abgleich zu wiederholen.
await entferneAusBericht(connectionId, geholt);
return { nachgeladen: geholt.length, fehler, uebrig };
}
/**
* Streicht die geholten Titel aus dem gespeicherten Bericht.
*
* Ohne das stünden sie nach dem Import weiter in der Auswahlliste, und man
* würde sie beim nächsten Mal erneut anhaken.
*/
async function entferneAusBericht(connectionId: string, tmdbIds: number[]) {
if (tmdbIds.length === 0) return;
const verbindung = await prisma.mediaServerConnection.findUnique({
where: { id: connectionId },
select: { lastSyncReport: true },
});
const bericht = verbindung?.lastSyncReport as SyncReport | null;
if (!bericht?.nichtZugeordnet) return;
const geholt = new Set(tmdbIds);
const rest = bericht.nichtZugeordnet.filter(
(eintrag) => eintrag.tmdbId == null || !geholt.has(eintrag.tmdbId),
);
await prisma.mediaServerConnection.update({
where: { id: connectionId },
data: {
lastSyncReport: {
...bericht,
nichtZugeordnet: rest,
nachgeladen: (bericht.nachgeladen ?? 0) + tmdbIds.length,
} as object,
},
});
}
/**
* Alle fälligen Verbindungen abgleichen — für den täglichen Lauf.
*
* Neue Titel werden dabei **nicht** automatisch aufgenommen: das ist eine
* Entscheidung, die jemand bewusst treffen soll, nicht etwas, das nachts
* ungefragt die Bibliothek füllt.
*/
export async function syncAllConnections(): Promise<{ verbindungen: number; berichte: SyncReport[] }> {
const verbindungen = await prisma.mediaServerConnection.findMany({
where: { enabled: true },
select: { id: true, userId: true, name: true },
});
const berichte: SyncReport[] = [];
for (const verbindung of verbindungen) {
try {
berichte.push(await syncConnection(verbindung.userId, verbindung.id));
} catch (error) {
const bericht = LEERER_BERICHT();
bericht.fehler.push(
`${verbindung.name}: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
);
berichte.push(bericht);
}
}
return { verbindungen: verbindungen.length, berichte };
}
+333
View File
@@ -0,0 +1,333 @@
import "server-only";
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
RegistrationResponseJSON,
} from "@simplewebauthn/server";
import { prisma } from "./db";
import { env } from "./env";
/**
* Anmeldung per Passkey (WebAuthn).
*
* Ein Passkey ist der dritte Weg in denselben Trichter: `finishLogin()` prüft
* die Antwort und gibt die Kennung zurück, der Endpunkt legt damit über
* `createSession()` dieselbe Sitzung an wie bei Passwort und OIDC. Die
* Sitzungsschicht weiß nicht, wie jemand hereingekommen ist.
*
* Die Trennung ist Absicht: hier steht die WebAuthn-Prüfung, das Setzen von
* Cookie und Sitzungszeile gehört zur HTTP-Schicht.
*
* Zwei Dinge sind bei einer selbst gehosteten Instanz wichtiger als sonst:
*
* - 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.
* - Ein **Domainwechsel entwertet alle Passkeys.** Das steht in der README;
* hier lässt sich nichts dagegen tun, es ist Teil des Verfahrens.
*/
export class PasskeyError extends Error {}
const CHALLENGE_COOKIE = "flimmer_passkey_challenge";
/** Kurz genug, dass eine liegengebliebene Challenge nicht ewig gilt. */
const CHALLENGE_SECONDS = 300;
function rpId(): string {
return env.passkeys.rpId;
}
/** Der Ursprung, den der Browser in der Antwort mitschickt. */
function expectedOrigin(): string {
return new URL(env.appUrl).origin;
}
// ---------------------------------------------------------------------------
// Challenge — signiertes Cookie statt Tabelle
// ---------------------------------------------------------------------------
/**
* Die Challenge muss den Weg vom ersten zum zweiten Schritt überstehen.
*
* Eine Tabelle bräuchte einen Aufräumjob für alles, was liegen bleibt, wenn
* jemand den Dialog abbricht. Ein signiertes, kurzlebiges Cookie kommt ohne
* beides aus: Fälschen geht nicht, und nach fünf Minuten ist es ohnehin weg.
*/
function sign(payload: string): string {
return createHmac("sha256", env.authSecret).update(payload).digest("base64url");
}
async function storeChallenge(challenge: string, scope: "register" | "login") {
const payload = `${scope}.${challenge}.${Date.now() + CHALLENGE_SECONDS * 1000}`;
const cookieStore = await cookies();
cookieStore.set(CHALLENGE_COOKIE, `${payload}.${sign(payload)}`, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: CHALLENGE_SECONDS,
});
}
async function takeChallenge(scope: "register" | "login"): Promise<string> {
const cookieStore = await cookies();
const raw = cookieStore.get(CHALLENGE_COOKIE)?.value;
// Einmalgebrauch: gleich löschen, egal ob die Prüfung gelingt.
cookieStore.delete(CHALLENGE_COOKIE);
if (!raw) throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
const teile = raw.split(".");
if (teile.length !== 4) throw new PasskeyError("Die Anfrage ist ungültig.");
const [gespeicherterScope, challenge, ablauf, signatur] = teile as [string, string, string, string];
const payload = `${gespeicherterScope}.${challenge}.${ablauf}`;
const erwartet = Buffer.from(sign(payload));
const gegeben = Buffer.from(signatur);
if (erwartet.length !== gegeben.length || !timingSafeEqual(erwartet, gegeben)) {
throw new PasskeyError("Die Anfrage ist ungültig.");
}
if (gespeicherterScope !== scope) throw new PasskeyError("Die Anfrage passt nicht.");
if (Number(ablauf) < Date.now()) {
throw new PasskeyError("Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.");
}
return challenge;
}
function requireAvailable() {
if (!env.passkeys.enabled) throw new PasskeyError("Passkeys sind auf dieser Instanz abgeschaltet.");
if (!env.passkeys.isAvailable) {
throw new PasskeyError(
"Passkeys brauchen HTTPS (oder localhost). Diese Instanz läuft über eine unverschlüsselte Verbindung.",
);
}
}
// ---------------------------------------------------------------------------
// Anlegen
// ---------------------------------------------------------------------------
export async function startRegistration(userId: string) {
requireAvailable();
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { id: true, email: true, name: true },
});
const vorhandene = await prisma.passkey.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options = await generateRegistrationOptions({
rpName: env.appName,
rpID: rpId(),
userName: user.email,
userDisplayName: user.name,
// Die Kennung muss über Umbenennungen hinweg stabil bleiben — deshalb die
// interne ID und nicht die E-Mail-Adresse.
userID: new TextEncoder().encode(user.id),
attestationType: "none",
// Schon vorhandene Passkeys ausschließen, damit dasselbe Gerät nicht
// zweimal registriert wird.
excludeCredentials: vorhandene.map((eintrag) => ({
id: eintrag.credentialId,
transports: eintrag.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
// Auffindbar, damit die Anmeldung ohne vorherige E-Mail-Eingabe geht.
residentKey: "required",
userVerification: "preferred",
},
});
await storeChallenge(options.challenge, "register");
return options;
}
export async function finishRegistration(
userId: string,
response: RegistrationResponseJSON,
name: string,
) {
requireAvailable();
const challenge = await takeChallenge("register");
const geprueft = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: expectedOrigin(),
expectedRPID: rpId(),
requireUserVerification: false,
});
if (!geprueft.verified || !geprueft.registrationInfo) {
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
}
const { credential, credentialDeviceType, credentialBackedUp } = geprueft.registrationInfo;
const schonVergeben = await prisma.passkey.findUnique({
where: { credentialId: credential.id },
select: { userId: true },
});
if (schonVergeben) {
throw new PasskeyError("Dieser Passkey ist bereits hinterlegt.");
}
return prisma.passkey.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: BigInt(credential.counter),
transports: (credential.transports ?? []) as string[],
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
name: name.trim().slice(0, 60) || "Passkey",
},
select: { id: true, name: true, deviceType: true, backedUp: true },
});
}
// ---------------------------------------------------------------------------
// Anmelden
// ---------------------------------------------------------------------------
export async function startLogin() {
requireAvailable();
// Ohne `allowCredentials`: der Browser sucht selbst einen passenden Passkey.
// Genau das erlaubt die Anmeldung ohne vorherige E-Mail-Eingabe — und
// verrät nebenbei nicht, welche Konten es auf dieser Instanz gibt.
const options = await generateAuthenticationOptions({
rpID: rpId(),
userVerification: "preferred",
});
await storeChallenge(options.challenge, "login");
return options;
}
export async function finishLogin(response: AuthenticationResponseJSON) {
requireAvailable();
const challenge = await takeChallenge("login");
const passkey = await prisma.passkey.findUnique({
where: { credentialId: response.id },
include: { user: { select: { id: true, isActive: true } } },
});
if (!passkey) throw new PasskeyError("Dieser Passkey gehört zu keinem Konto hier.");
if (!passkey.user.isActive) throw new PasskeyError("Dieses Konto ist gesperrt.");
// Die Bibliothek prüft Signatur, Ursprung, RP ID **und** den Zähler. Sie
// wirft dabei gewöhnliche Error — hier übersetzt, damit eine erwartete
// Ablehnung nicht als unerwarteter Serverfehler im Log landet.
let geprueft;
try {
geprueft = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: expectedOrigin(),
expectedRPID: rpId(),
requireUserVerification: false,
credential: {
id: passkey.credentialId,
publicKey: new Uint8Array(passkey.publicKey),
counter: Number(passkey.counter),
transports: passkey.transports as AuthenticatorTransportFuture[],
},
});
} catch (error) {
const text = error instanceof Error ? error.message : "";
if (/counter/i.test(text)) {
throw new PasskeyError(
"Dieser Passkey wurde abgelehnt — der Zähler passt nicht. Melde dich anders an und lege ihn neu an.",
);
}
throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
}
if (!geprueft.verified) throw new PasskeyError("Der Passkey konnte nicht bestätigt werden.");
// Zusätzliche Sicherung. Die Bibliothek prüft den Zähler nur, wenn beide
// Werte über null liegen; ein Authenticator, der von einem gezählten auf
// einen ungezählten Stand zurückfällt, käme dort durch.
const neuerZaehler = geprueft.authenticationInfo.newCounter;
const alterZaehler = Number(passkey.counter);
if (alterZaehler > 0 && neuerZaehler <= alterZaehler) {
throw new PasskeyError("Dieser Passkey wurde abgelehnt. Bitte melde dich anders an.");
}
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(neuerZaehler), lastUsedAt: new Date() },
});
return { userId: passkey.user.id };
}
// ---------------------------------------------------------------------------
// Verwalten
// ---------------------------------------------------------------------------
export async function listPasskeys(userId: string) {
return prisma.passkey.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
deviceType: true,
backedUp: true,
createdAt: true,
lastUsedAt: true,
},
});
}
export async function renamePasskey(userId: string, passkeyId: string, name: string) {
const treffer = await prisma.passkey.updateMany({
where: { id: passkeyId, userId },
data: { name: name.trim().slice(0, 60) || "Passkey" },
});
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
}
/**
* Entfernt einen Passkey — aber nie den letzten Anmeldeweg.
*
* Wer ein Konto ohne Passwort hat (etwa über OIDC angelegt) und seinen einzigen
* Passkey löscht, käme sonst nicht mehr herein.
*/
export async function deletePasskey(userId: string, passkeyId: string) {
const [user, anzahl] = await Promise.all([
prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { passwordHash: true, _count: { select: { oidcAccounts: true } } },
}),
prisma.passkey.count({ where: { userId } }),
]);
const andereWege = Boolean(user.passwordHash) || user._count.oidcAccounts > 0;
if (!andereWege && anzahl <= 1) {
throw new PasskeyError(
"Das ist dein letzter Anmeldeweg. Lege zuerst ein Passwort oder einen weiteren Passkey an.",
);
}
const treffer = await prisma.passkey.deleteMany({ where: { id: passkeyId, userId } });
if (treffer.count === 0) throw new PasskeyError("Diesen Passkey gibt es nicht.");
}
+53
View File
@@ -0,0 +1,53 @@
import "server-only";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { env } from "./env";
/**
* Verschlüsseln von Geheimnissen, die in der Datenbank liegen müssen.
*
* Anders als Passwörter und Sitzungstoken lassen sich Zugangstoken fremder
* Dienste nicht hashen — sie werden im Klartext gebraucht, um sich beim
* Medienserver anzumelden. Sie dürfen aber auch nicht im Klartext in der
* Datenbank stehen: ein Datenbank-Dump landet schneller irgendwo, als einem
* lieb ist.
*
* AES-256-GCM mit einem aus `AUTH_SECRET` abgeleiteten Schlüssel. GCM prüft
* beim Entschlüsseln gleich mit, ob der Inhalt unverändert ist.
*
* Zu wissen: **Wer `AUTH_SECRET` austauscht, macht die gespeicherten Token
* unlesbar.** Sie müssen dann neu hinterlegt werden — das ist derselbe
* Effekt, den ein neues Secret ohnehin auf alle Sitzungen hat.
*/
const ALGORITHM = "aes-256-gcm";
function key(): Buffer {
// AUTH_SECRET ist eine beliebig lange Zeichenkette; AES braucht genau 32 Byte.
return createHash("sha256").update(`medienserver:${env.authSecret}`).digest();
}
export function encryptSecret(plain: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, key(), iv);
const verschluesselt = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv.toString("base64url"), tag.toString("base64url"), verschluesselt.toString("base64url")].join(
".",
);
}
export function decryptSecret(stored: string): string {
const teile = stored.split(".");
if (teile.length !== 3) throw new Error("Das gespeicherte Geheimnis hat ein unbekanntes Format.");
const [iv, tag, nutzlast] = teile as [string, string, string];
const decipher = createDecipheriv(ALGORITHM, key(), Buffer.from(iv, "base64url"));
decipher.setAuthTag(Buffer.from(tag, "base64url"));
return Buffer.concat([
decipher.update(Buffer.from(nutzlast, "base64url")),
decipher.final(),
]).toString("utf8");
}
+35
View File
@@ -3,6 +3,8 @@ import { prisma } from "./db";
import { env } from "./env";
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
import { notifyUser } from "./notify";
import { syncAllConnections } from "./mediaserver";
import { evaluateAllAchievements } from "./achievements";
import { episodeCode } from "./labels";
/**
@@ -26,6 +28,9 @@ export type SyncStats = {
notificationsSent: number;
entriesPurged: number;
sessionsPurged: number;
mediaServersSynced: number;
mediaServerWatches: number;
achievementsUnlocked: number;
errors: string[];
};
@@ -232,6 +237,9 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
notificationsSent: 0,
entriesPurged: 0,
sessionsPurged: 0,
mediaServersSynced: 0,
mediaServerWatches: 0,
achievementsUnlocked: 0,
errors: [],
};
@@ -283,6 +291,33 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
);
}
// Medienserver abgleichen. Neue Titel werden dabei bewusst nicht
// aufgenommen — das soll niemand nachts ungefragt in der Bibliothek finden.
try {
const medien = await syncAllConnections();
stats.mediaServersSynced = medien.verbindungen;
for (const bericht of medien.berichte) {
stats.mediaServerWatches += bericht.sichtungenAngelegt;
stats.errors.push(...bericht.fehler);
}
} catch (error) {
stats.errors.push(
`Medienserver: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
);
}
// Errungenschaften: einmal täglich vollständig. Die Regeln, die nicht an
// einer Sichtung hängen (Zeitspannen, abgeschlossene Reihen), finden hier
// ihren Weg — inline waeren sie zu teuer.
try {
const abzeichen = await evaluateAllAchievements();
stats.achievementsUnlocked = abzeichen.vergeben;
} catch (error) {
stats.errors.push(
`Errungenschaften: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
);
}
const purged = await purgeExpired();
stats.entriesPurged = purged.entries;
stats.sessionsPurged = purged.sessions;
+43 -2
View File
@@ -244,13 +244,54 @@ export async function renameOrder(userId: string, orderId: string, name: string)
export async function deleteOrder(userId: string, orderId: string) {
const order = await prisma.universeOrder.findUniqueOrThrow({
where: { id: orderId },
select: { universeId: true, kind: true },
select: { universeId: true, kind: true, isDefault: true },
});
await requireWrite(userId, order.universeId);
if (order.kind === "RELEASE") {
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
}
await prisma.universeOrder.delete({ where: { id: orderId } });
await prisma.$transaction(async (tx) => {
await tx.universeOrder.delete({ where: { id: orderId } });
// War die gelöschte der Standard, stünde das Universum ohne einen da und
// die Auswahl fiele auf „die erste" — Zufall statt Absicht. Die
// Erscheinungsreihenfolge gehört immer dazu, sie übernimmt.
if (order.isDefault) {
await tx.universeOrder.updateMany({
where: { universeId: order.universeId, kind: "RELEASE" },
data: { isDefault: true },
});
}
});
}
/**
* Legt fest, welche Reihenfolge beim Öffnen des Universums erscheint.
*
* Der Standard gehört dem Universum, nicht der einzelnen Person: wer
* Schreibrecht hat, setzt ihn für alle, die das Universum sehen — so wie die
* Reihenfolgen selbst auch geteilt sind.
*
* Zurücksetzen und Setzen laufen gemeinsam in einer Transaktion. Sonst stünden
* dazwischen zwei Standards nebeneinander, und die Auswahl in `getUniverse()`
* griffe sich nach Sortierzufall einen davon.
*/
export async function setDefaultOrder(userId: string, orderId: string) {
const order = await prisma.universeOrder.findUniqueOrThrow({
where: { id: orderId },
select: { universeId: true, isDefault: true },
});
await requireWrite(userId, order.universeId);
if (order.isDefault) return;
await prisma.$transaction([
prisma.universeOrder.updateMany({
where: { universeId: order.universeId, isDefault: true },
data: { isDefault: false },
}),
prisma.universeOrder.update({ where: { id: orderId }, data: { isDefault: true } }),
]);
}
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
+254
View File
@@ -0,0 +1,254 @@
import "./setup";
import assert from "node:assert/strict";
import test, { after, before, beforeEach } from "node:test";
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./fixtures/tmdb";
import { prisma } from "../src/lib/db";
import { importMovie, importSeries } from "../src/lib/catalog";
import { addToLibrary, markMovieWatched, markEpisodesWatched } from "../src/lib/library";
import {
achievementName,
evaluateAchievements,
listAchievements,
listUnlockedAchievements,
} from "../src/lib/achievements";
import { kuratierteReihe } from "../src/lib/achievement-names";
/**
* Prüft die Errungenschaften: Vergabe, Entdopplung, die Stummschaltung beim
* ersten Lauf und die Absicherung gegen falsch abgeschriebene Reihen.
*/
const tmdb = installTmdbStub();
const TEST_EMAIL = "test-achievements@flimmer.invalid";
let userId: string;
let seriesTitleId: string;
let movieTitleId: string;
async function aufraeumen() {
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
}
before(async () => {
await aufraeumen();
userId = (
await prisma.user.create({
data: { email: TEST_EMAIL, name: "Sammlerin" },
select: { id: true },
})
).id;
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
await importMovie(MOVIE_2_ID, "DE");
});
beforeEach(async () => {
await prisma.achievement.deleteMany({ where: { userId } });
await prisma.watch.deleteMany({ where: { userId } });
await prisma.libraryEntry.deleteMany({ where: { userId } });
});
after(async () => {
await aufraeumen();
tmdb.restore();
await prisma.$disconnect();
});
// ---------------------------------------------------------------------------
test("der erste Lauf eines Kontos ist stumm", async () => {
// Bestandskonten bringen Jahre an Daten mit. Zwei Dutzend Meldungen auf
// einmal waeren Laerm, kein Erfolgserlebnis.
await addToLibrary(userId, movieTitleId, "WATCHLIST");
const erster = await evaluateAchievements(userId);
assert.equal(erster.stumm, true, "der erste Lauf meldet nichts");
assert.deepEqual(erster.neu, [], "auch wenn er Abzeichen vergibt");
// Vergeben wurde trotzdem.
const vergeben = await prisma.achievement.count({ where: { userId } });
assert.ok(vergeben > 0, "Erstkontakt ist erreicht");
});
test("ab dem zweiten Lauf wird gemeldet", async () => {
await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId); // stummer Erstlauf
const eintrag = await prisma.libraryEntry.findFirstOrThrow({
where: { userId, titleId: movieTitleId },
select: { id: true },
});
await markMovieWatched(userId, eintrag.id);
await prisma.libraryEntry.update({ where: { id: eintrag.id }, data: { currentRun: 2 } });
const zweiter = await evaluateAchievements(userId);
assert.equal(zweiter.stumm, false);
assert.ok(
zweiter.neu.some((abzeichen) => abzeichen.key === "wiederholungstaeter"),
"der dritte Durchlauf wird gemeldet",
);
});
test("ein zweiter Lauf vergibt nichts doppelt", async () => {
await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId);
const nachErstem = await prisma.achievement.count({ where: { userId } });
const zweiter = await evaluateAchievements(userId);
assert.deepEqual(zweiter.neu, []);
assert.equal(await prisma.achievement.count({ where: { userId } }), nachErstem);
});
test("einmal erreicht bleibt erreicht — auch wenn die Bedingung wegfällt", async () => {
const eintrag = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId);
assert.ok(
(await prisma.achievement.findFirst({ where: { userId, key: "erstkontakt" } })) != null,
);
// Titel wieder entfernen: die Bedingung ist nicht mehr erfüllt.
await prisma.libraryEntry.delete({ where: { id: eintrag.id } });
await evaluateAchievements(userId);
assert.ok(
(await prisma.achievement.findFirst({ where: { userId, key: "erstkontakt" } })) != null,
"das Abzeichen bleibt stehen",
);
});
test("Durchgemacht rechnet in der Zeitzone der Instanz", async () => {
const eintrag = await addToLibrary(userId, seriesTitleId, "WATCHING");
const episoden = await prisma.episode.findMany({
where: { titleId: seriesTitleId },
take: 1,
select: { id: true },
});
await markEpisodesWatched(userId, eintrag.id, [episoden[0]!.id]);
// 03:30 Uhr in Europe/Berlin ist im Sommer 01:30 UTC. Wer in UTC rechnet,
// verpasst das Abzeichen — genau der Fehler, der schon einmal das
// Monatsdiagramm leergefegt hat.
await prisma.watch.updateMany({
where: { userId },
data: { watchedAt: new Date("2026-07-15T01:30:00.000Z") },
});
await evaluateAchievements(userId);
const nachts = await prisma.achievement.findFirst({ where: { userId, key: "durchgemacht" } });
assert.ok(nachts, "03:30 Ortszeit zählt als Nacht");
});
test("Tagsueber gesehen loest Durchgemacht nicht aus", async () => {
const eintrag = await addToLibrary(userId, seriesTitleId, "WATCHING");
const episoden = await prisma.episode.findMany({
where: { titleId: seriesTitleId },
take: 1,
select: { id: true },
});
await markEpisodesWatched(userId, eintrag.id, [episoden[0]!.id]);
await prisma.watch.updateMany({
where: { userId },
data: { watchedAt: new Date("2026-07-15T12:00:00.000Z") },
});
await evaluateAchievements(userId);
assert.equal(await prisma.achievement.count({ where: { userId, key: "durchgemacht" } }), 0);
});
test("die Übersicht zeigt Erreichtes und Offenes", async () => {
await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId);
const alle = await listAchievements(userId);
assert.ok(alle.length > 15, "der Katalog ist vollständig sichtbar");
const erreicht = alle.filter((abzeichen) => abzeichen.unlockedAt !== null);
const offen = alle.filter((abzeichen) => abzeichen.unlockedAt === null);
assert.ok(erreicht.length >= 1);
assert.ok(offen.length >= 1, "auch das noch nicht Erreichte steht da");
assert.deepEqual(
await listUnlockedAchievements(userId),
erreicht,
"das öffentliche Profil zeigt nur Erreichtes",
);
// Jedes Abzeichen trägt Name und Beschreibung — Farbe allein reicht nicht.
assert.ok(alle.every((abzeichen) => abzeichen.name && abzeichen.beschreibung));
});
test("ein kuratierter Name gilt nur, wenn er zur Reihe passt", async () => {
// Richtige Kennung mit passendem Namen: greift.
assert.equal(kuratierteReihe(556, "Spider-Man Filmreihe")?.name, "Spider Bro");
// Dieselbe Kennung, aber TMDB führt etwas ganz anderes darunter — dann darf
// der Eintrag nicht greifen. Genau so bleibt eine falsch abgeschriebene
// Kennung folgenlos.
assert.equal(kuratierteReihe(556, "Ocean's Filmreihe"), undefined);
// Unbekannte Kennung: nichts.
assert.equal(kuratierteReihe(999999, "Irgendwas"), undefined);
// Ohne Namen lässt sich nichts prüfen — im Zweifel generisch.
assert.equal(kuratierteReihe(556, null), undefined);
});
test("Anzeigenamen entstehen aus Schlüssel und eingefrorenem Namen", () => {
assert.equal(achievementName("marathon", null), "Marathon");
assert.equal(achievementName("reihe:556", "Spider-Man Filmreihe"), "Spider Bro");
assert.equal(
achievementName("reihe:12345", "Nordlicht Filmreihe"),
"Reihe komplett — Nordlicht Filmreihe",
"ohne kuratierten Eintrag der generische Name",
);
assert.equal(
achievementName("universum:abc", "Sternenpfad"),
"Universum vollendet — Sternenpfad",
);
});
test("ein abgeschlossenes Universum wird zum Abzeichen", async () => {
const eintrag = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await markMovieWatched(userId, eintrag.id);
const universum = await prisma.universe.create({
data: {
ownerId: userId,
name: "Testuniversum",
slug: `test-achievements-${Date.now()}`,
items: { create: [{ titleId: movieTitleId }] },
orders: { create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }] },
},
select: { id: true },
});
try {
await evaluateAchievements(userId);
const abzeichen = await prisma.achievement.findFirst({
where: { userId, key: `universum:${universum.id}` },
select: { label: true },
});
assert.ok(abzeichen, "das vollendete Universum zählt");
assert.equal(abzeichen.label, "Testuniversum", "der Name wird eingefroren");
// Umbenennen ändert das Abzeichen nicht mehr.
await prisma.universe.update({
where: { id: universum.id },
data: { name: "Anders benannt" },
});
await evaluateAchievements(userId);
const danach = await prisma.achievement.findFirstOrThrow({
where: { userId, key: `universum:${universum.id}` },
select: { label: true },
});
assert.equal(danach.label, "Testuniversum");
} finally {
await prisma.universe.delete({ where: { id: universum.id } });
}
});
+172
View File
@@ -0,0 +1,172 @@
import { SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./tmdb";
/**
* Nachgebildete Jellyfin-Antworten.
*
* Damit lässt sich der ganze Abgleich prüfen, ohne einen Jellyfin-Server zu
* brauchen. Der Aufbau entspricht dem, was Jellyfin tatsächlich liefert —
* gekürzt auf die Felder, die Flimmer liest.
*
* Absichtlich enthalten: ein Item **ohne** TMDB-Kennung und eines mit einer
* Kennung, die nicht im Katalog steht. Beides muss im Bericht landen, statt
* den Lauf zu stören.
*/
export const JELLYFIN_URL = "http://jellyfin.test:8096";
export const JELLYFIN_USER_ID = "jf-user-0001";
export const JELLYFIN_TOKEN = "jf-token-abcdef";
export const SERIES_ITEM_ID = "jf-series-1";
export const items = {
/** Film, gesehen — mit Termin. */
filmGesehen: {
Id: "jf-movie-1",
Name: "Die Stille zwischen den Bildern",
Type: "Movie",
ProviderIds: { Tmdb: String(MOVIE_ID) },
ProductionYear: 2024,
UserData: { Played: true, PlayCount: 3, LastPlayedDate: "2026-07-14T20:15:00.0000000Z" },
},
/** Film, vorhanden aber nicht gesehen. */
filmUngesehen: {
Id: "jf-movie-2",
Name: "Die Stille zwischen den Bildern II",
Type: "Movie",
ProviderIds: { Tmdb: String(MOVIE_2_ID) },
ProductionYear: 2026,
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
},
/** Episode, gesehen — die TMDB-Kennung hängt an der Serie darüber. */
episodeGesehen: {
Id: "jf-episode-1",
Name: "Erste Nacht",
Type: "Episode",
SeriesId: SERIES_ITEM_ID,
SeriesName: "Nordlicht",
ParentIndexNumber: 1,
IndexNumber: 1,
ProviderIds: {},
UserData: { Played: true, PlayCount: 1, LastPlayedDate: "2026-08-01T21:00:00.0000000Z" },
},
/** Episode, ungesehen. */
episodeUngesehen: {
Id: "jf-episode-2",
Name: "Zweite Nacht",
Type: "Episode",
SeriesId: SERIES_ITEM_ID,
SeriesName: "Nordlicht",
ParentIndexNumber: 1,
IndexNumber: 2,
ProviderIds: {},
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
},
/** Ohne TMDB-Kennung — landet im Bericht. */
ohneKennung: {
Id: "jf-movie-3",
Name: "Urlaubsvideo 2019",
Type: "Movie",
ProviderIds: {},
UserData: { Played: true, PlayCount: 1, LastPlayedDate: "2026-01-01T12:00:00.0000000Z" },
},
/** Kennung vorhanden, aber nicht im Katalog — landet ebenfalls im Bericht. */
fremd: {
Id: "jf-movie-4",
Name: "Ein fremder Film",
Type: "Movie",
ProviderIds: { Tmdb: "424242" },
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
},
/**
* Zweite Datei zu demselben fremden Film — so führt Jellyfin Extended
* Editions. In der Auswahlliste muss der Titel trotzdem einmal stehen.
*/
fremdZweiteFassung: {
Id: "jf-movie-5",
Name: "Ein fremder Film (Extended)",
Type: "Movie",
ProviderIds: { Tmdb: "424242" },
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
},
} as const;
export const seriesItem = {
Id: SERIES_ITEM_ID,
Name: "Nordlicht",
Type: "Series",
ProviderIds: { Tmdb: String(SERIES_ID) },
};
export const ALLE_ITEMS = Object.values(items);
type StubOptions = {
/** Ohne UserData — so verhält sich Jellyfin bei Anmeldung per API-Key. */
ohneUserData?: boolean;
/** Anmeldung ablehnen. */
abweisen?: boolean;
};
/**
* Hängt sich in `fetch` und beantwortet die Jellyfin-Endpunkte.
*
* Gibt die aufgerufenen Pfade zurück — damit lässt sich prüfen, dass ein
* zweiter Lauf nicht mehr Arbeit macht als nötig.
*/
export function installJellyfinStub(options: StubOptions = {}) {
const original = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(typeof input === "string" ? input : input.toString());
calls.push(url.pathname);
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
if (url.pathname === "/Users/AuthenticateByName") {
if (options.abweisen) return new Response("", { status: 401 });
return json({
AccessToken: JELLYFIN_TOKEN,
User: { Id: JELLYFIN_USER_ID, Name: "florian" },
});
}
if (url.pathname === "/System/Info") {
return json({ ServerName: "Heimserver", Version: "10.10.3" });
}
if (url.pathname === "/Items") {
const ids = url.searchParams.get("Ids");
if (ids) {
// Abfrage der Serien-Kopfdaten.
const gesucht = new Set(ids.split(","));
return json({ Items: gesucht.has(SERIES_ITEM_ID) ? [seriesItem] : [] });
}
const startIndex = Number(url.searchParams.get("StartIndex") ?? "0");
const limit = Number(url.searchParams.get("Limit") ?? "500");
const alle = ALLE_ITEMS.map((item) =>
options.ohneUserData ? { ...item, UserData: undefined } : item,
);
return json({
Items: alle.slice(startIndex, startIndex + limit),
TotalRecordCount: alle.length,
});
}
return new Response(JSON.stringify({ error: `Kein Fixture für ${url.pathname}` }), {
status: 404,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
return {
calls,
restore() {
globalThis.fetch = original;
},
};
}
+54
View File
@@ -21,6 +21,7 @@ import {
startRewatch,
undoWatches,
unmarkEpisodes,
unmarkMovie,
updateEntryDetails,
watchNextEpisode,
} from "../src/lib/library";
@@ -388,3 +389,56 @@ test("Sammlung: Filter und Zähler in der Bibliothek", async () => {
await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } });
});
test("Filme: die Sichtung laesst sich wieder zuruecknehmen", async () => {
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await markMovieWatched(userId, entry.id);
assert.equal((await getProgress(userId, entry.id)).percent, 100);
await unmarkMovie(userId, entry.id);
const zurueck = await getProgress(userId, entry.id);
assert.equal(zurueck.percent, 0, "der Fortschritt steht wieder auf null");
assert.equal(zurueck.minutesWatched, 0);
assert.equal(
await prisma.watch.count({ where: { userId, entryId: entry.id } }),
0,
"die Sichtung ist weg",
);
// Und danach laesst es sich erneut abhaken — kein Einbahnzustand.
const erneut = await markMovieWatched(userId, entry.id);
assert.equal(erneut.watchIds.length, 1);
});
test("Filme: das Zuruecknehmen betrifft nur den laufenden Durchlauf", async () => {
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await markMovieWatched(userId, entry.id);
await startRewatch(userId, entry.id);
await markMovieWatched(userId, entry.id);
assert.equal(await prisma.watch.count({ where: { userId, entryId: entry.id } }), 2);
await unmarkMovie(userId, entry.id);
const rest = await prisma.watch.findMany({
where: { userId, entryId: entry.id },
select: { run: true },
});
assert.deepEqual(
rest.map((watch) => watch.run),
[0],
"die Sichtung des ersten Durchlaufs bleibt fuer die Statistik stehen",
);
assert.equal(
(await getProgress(userId, entry.id)).percent,
0,
"der laufende Durchlauf steht wieder auf null",
);
});
test("unmarkMovie weist Serien ab", async () => {
const entry = await addToLibrary(userId, seriesTitleId, "WATCHING");
await assert.rejects(() => unmarkMovie(userId, entry.id), /keine Filmsichtung/);
});
+469
View File
@@ -0,0 +1,469 @@
import "./setup";
import assert from "node:assert/strict";
import test, { after, before, beforeEach } from "node:test";
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./fixtures/tmdb";
import { installJellyfinStub, JELLYFIN_URL, JELLYFIN_TOKEN } from "./fixtures/jellyfin";
import { prisma } from "../src/lib/db";
import { importMovie, importSeries } from "../src/lib/catalog";
import { addToLibrary, markMovieWatched } from "../src/lib/library";
import { decryptSecret, encryptSecret } from "../src/lib/secrets";
import {
connectJellyfin,
deleteConnection,
importUnassigned,
syncConnection,
testConnection,
} from "../src/lib/mediaserver";
/**
* Prüft den Abgleich mit einem Medienserver — gegen nachgebildete Antworten,
* ohne echten Jellyfin-Server.
*/
const tmdb = installTmdbStub();
const TEST_EMAIL = "test-mediaserver@flimmer.invalid";
let userId: string;
let movieTitleId: string;
let movie2TitleId: string;
async function aufraeumen() {
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
}
before(async () => {
await aufraeumen();
userId = (
await prisma.user.create({ data: { email: TEST_EMAIL, name: "Medienserver" }, select: { id: true } })
).id;
await importSeries(SERIES_ID, { region: "DE" });
movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
movie2TitleId = (await importMovie(MOVIE_2_ID, "DE")).id;
});
beforeEach(async () => {
await prisma.mediaServerConnection.deleteMany({ where: { userId } });
await prisma.watch.deleteMany({ where: { userId } });
await prisma.libraryEntry.deleteMany({ where: { userId } });
});
after(async () => {
await aufraeumen();
tmdb.restore();
await prisma.$disconnect();
});
async function verbinden() {
const { verbindung } = await connectJellyfin(userId, {
baseUrl: JELLYFIN_URL,
username: "florian",
password: "geheim",
});
return verbindung;
}
// ---------------------------------------------------------------------------
test("Geheimnisse gehen verschlüsselt hinein und im Klartext heraus", () => {
const klartext = "jf-token-abcdef";
const abgelegt = encryptSecret(klartext);
assert.notEqual(abgelegt, klartext, "im Klartext hat der Token nichts in der Datenbank verloren");
assert.ok(!abgelegt.includes(klartext));
assert.equal(decryptSecret(abgelegt), klartext);
// Zweimal verschlüsseln ergibt Verschiedenes — der Zufallswert wandert mit.
assert.notEqual(encryptSecret(klartext), abgelegt);
});
test("eine veränderte Ablage lässt sich nicht entschlüsseln", () => {
const abgelegt = encryptSecret("geheim");
const teile = abgelegt.split(".");
const verbogen = `${teile[0]}.${teile[1]}.${"A".repeat(teile[2]!.length)}`;
assert.throws(() => decryptSecret(verbogen));
});
test("die Verbindung wird eingerichtet und der Token verschlüsselt abgelegt", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
assert.equal(verbindung.name, "Heimserver", "der Servername kommt vom Server");
assert.equal(verbindung.serverUserName, "florian");
const roh = await prisma.mediaServerConnection.findUniqueOrThrow({
where: { id: verbindung.id },
select: { token: true },
});
assert.notEqual(roh.token, JELLYFIN_TOKEN, "der Token steht nicht im Klartext in der Datenbank");
assert.equal(decryptSecret(roh.token), JELLYFIN_TOKEN);
} finally {
jf.restore();
}
});
test("fehlt UserData, sagt die Prüfung das deutlich", async () => {
// So verhält sich Jellyfin bei Anmeldung per API-Key: Bestand ja,
// Gesehen-Status nein — und zwar ohne Fehlermeldung.
const jf = installJellyfinStub({ ohneUserData: true });
try {
const verbindung = await verbinden();
const pruefung = await testConnection(userId, verbindung.id);
assert.equal(pruefung.hasUserData, false, "das Fehlen wird erkannt, statt stumm zu bleiben");
assert.ok(pruefung.itemCount > 0, "der Bestand kommt trotzdem an");
} finally {
jf.restore();
}
});
test("eine abgelehnte Anmeldung wird gemeldet", async () => {
const jf = installJellyfinStub({ abweisen: true });
try {
await assert.rejects(() => verbinden(), /abgelehnt/);
} finally {
jf.restore();
}
});
test("Bestand wird zugeordnet, Unzuordenbares landet im Bericht", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
// Nur der eine Film ist in der Bibliothek — der Rest soll nicht ungefragt
// hineinwandern.
await addToLibrary(userId, movieTitleId, "WATCHLIST");
const bericht = await syncConnection(userId, verbindung.id);
assert.equal(bericht.gelesen, 7, "alle Items wurden gelesen");
assert.equal(bericht.aufgenommen, 0, "ohne ausdrückliche Erlaubnis wird nichts aufgenommen");
assert.equal(bericht.bestandGesetzt, 1, "nur der vorhandene Eintrag bekommt „hab ich“");
const eintrag = await prisma.libraryEntry.findFirstOrThrow({
where: { userId, titleId: movieTitleId },
select: { owned: true, ownedSource: true },
});
assert.equal(eintrag.owned, true);
assert.equal(eintrag.ownedSource, "Jellyfin");
const gruende = bericht.nichtZugeordnet.map((eintrag) => eintrag.grund);
assert.ok(
gruende.some((grund) => grund.includes("keine TMDB")),
"das Urlaubsvideo ohne Kennung steht im Bericht",
);
assert.ok(
gruende.some((grund) => grund.includes("nicht im Katalog")),
"der fremde Film steht im Bericht",
);
// Die zwei Dateien zum fremden Film stehen als ein Eintrag da.
assert.equal(
bericht.nichtZugeordnet.length,
2,
"zusammengefasst: ein fremder Film und ein Video ohne Kennung",
);
} finally {
jf.restore();
}
});
test("mit Erlaubnis werden fehlende Titel aufgenommen", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
const bericht = await syncConnection(userId, verbindung.id, { aufnehmen: true });
assert.equal(bericht.aufgenommen, 3, "Serie und beide Filme kommen dazu");
const eintraege = await prisma.libraryEntry.findMany({
where: { userId },
select: { titleId: true, status: true, owned: true, ownedSource: true },
});
assert.equal(eintraege.length, 3);
assert.ok(eintraege.every((eintrag) => eintrag.status === "WATCHLIST"));
assert.ok(eintraege.every((eintrag) => eintrag.owned && eintrag.ownedSource === "Jellyfin"));
} finally {
jf.restore();
}
});
test("gesehene Filme und Episoden werden mit dem Termin aus Jellyfin übernommen", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
await syncConnection(userId, verbindung.id, { aufnehmen: true });
const sichtungen = await prisma.watch.findMany({
where: { userId },
select: { titleId: true, episodeId: true, watchedAt: true, source: true, minutes: true },
});
assert.equal(sichtungen.length, 2, "ein Film und eine Episode wurden gesehen");
assert.ok(sichtungen.every((sichtung) => sichtung.source === "JELLYFIN"));
const film = sichtungen.find((sichtung) => sichtung.episodeId === null);
assert.ok(film, "der gesehene Film ist dabei");
assert.equal(
film.watchedAt.toISOString(),
new Date("2026-07-14T20:15:00.000Z").toISOString(),
"der Termin kommt aus Jellyfin, nicht von jetzt",
);
assert.ok(film.minutes > 0, "die Laufzeit wird angerechnet");
const episode = sichtungen.find((sichtung) => sichtung.episodeId !== null);
assert.ok(episode);
assert.equal(
episode.watchedAt.toISOString(),
new Date("2026-08-01T21:00:00.000Z").toISOString(),
);
// Der ungesehene Film bleibt ungesehen.
const ungesehen = sichtungen.filter((sichtung) => sichtung.titleId === movie2TitleId);
assert.equal(ungesehen.length, 0);
} finally {
jf.restore();
}
});
test("ein zweiter Lauf legt nichts doppelt an", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
const erster = await syncConnection(userId, verbindung.id, { aufnehmen: true });
assert.equal(erster.sichtungenAngelegt, 2);
const zweiter = await syncConnection(userId, verbindung.id, { aufnehmen: true });
assert.equal(zweiter.sichtungenAngelegt, 0, "beim zweiten Mal gibt es nichts Neues");
assert.equal(zweiter.aufgenommen, 0);
assert.equal(await prisma.watch.count({ where: { userId } }), 2);
assert.equal(await prisma.libraryEntry.count({ where: { userId } }), 3);
} finally {
jf.restore();
}
});
test("der Abgleich löscht nichts, was in Flimmer steht", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
// In Flimmer als gesehen markiert — in Jellyfin steht dieser Film auf
// ungesehen. Der Abgleich darf ihn nicht zurücksetzen.
const eintrag = await addToLibrary(userId, movie2TitleId, "WATCHLIST");
await markMovieWatched(userId, eintrag.id);
const vorher = await prisma.watch.count({ where: { userId } });
await syncConnection(userId, verbindung.id, { aufnehmen: true });
const eigene = await prisma.watch.findMany({
where: { userId, source: "FLIMMER" },
select: { titleId: true },
});
assert.equal(eigene.length, vorher, "die eigene Sichtung steht unverändert da");
assert.equal(eigene[0]!.titleId, movie2TitleId);
const nachher = await prisma.libraryEntry.findFirstOrThrow({
where: { userId, titleId: movie2TitleId },
select: { status: true },
});
assert.notEqual(nachher.status, "WATCHLIST", "der Status aus Flimmer bleibt erhalten");
} finally {
jf.restore();
}
});
test("eine gelöschte Verbindung nimmt ihre Zuordnungen mit, nicht die Sichtungen", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
await syncConnection(userId, verbindung.id, { aufnehmen: true });
assert.ok((await prisma.mediaServerItem.count({ where: { connectionId: verbindung.id } })) > 0);
const sichtungenVorher = await prisma.watch.count({ where: { userId } });
await deleteConnection(userId, verbindung.id);
assert.equal(await prisma.mediaServerItem.count({ where: { connectionId: verbindung.id } }), 0);
assert.equal(
await prisma.watch.count({ where: { userId } }),
sichtungenVorher,
"die übernommenen Sichtungen bleiben — sie gehören jetzt dem Nutzer",
);
} finally {
jf.restore();
}
});
test("fremde Verbindungen sind unerreichbar", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
const fremderId = (
await prisma.user.create({
data: { email: "test-mediaserver-fremd@flimmer.invalid", name: "Fremd" },
select: { id: true },
})
).id;
try {
await assert.rejects(() => syncConnection(fremderId, verbindung.id), /gibt es nicht/);
await assert.rejects(() => deleteConnection(fremderId, verbindung.id), /gibt es nicht/);
} finally {
await prisma.user.delete({ where: { id: fremderId } });
}
} finally {
jf.restore();
}
});
// --- Nachladen unbekannter Titel (#16) ---------------------------------------
test("der Bericht fasst Dubletten zusammen und nennt Typ und Kennung", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
const bericht = await syncConnection(userId, verbindung.id);
// Zwei Jellyfin-Dateien zeigen auf denselben fremden Film.
const fremd = bericht.nichtZugeordnet.filter((eintrag) => eintrag.tmdbId === 424242);
assert.equal(fremd.length, 1, "derselbe Titel steht nur einmal in der Liste");
assert.equal(fremd[0]!.dateien, 2, "beide Dateien werden gezaehlt");
assert.equal(fremd[0]!.typ, "MOVIE");
// Das Urlaubsvideo hat keine Kennung — es steht drin, aber ohne Typ.
const ohneKennung = bericht.nichtZugeordnet.filter((eintrag) => eintrag.tmdbId === null);
assert.equal(ohneKennung.length, 1);
assert.equal(ohneKennung[0]!.typ, null, "ohne Kennung laesst sich nichts holen");
assert.equal(bericht.weitereNichtZugeordnet, 0, "nichts abgeschnitten");
} finally {
jf.restore();
}
});
test("ausgewaehlte Titel werden aus TMDB nachgeladen", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
await syncConnection(userId, verbindung.id);
// MOVIE_2_ID kennt der TMDB-Stub, steht aber in diesem Lauf nicht in der
// Bibliothek — genau der Fall, den #16 loest.
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
const ergebnis = await importUnassigned(userId, verbindung.id, [
{ tmdbId: MOVIE_2_ID, typ: "MOVIE" },
]);
assert.equal(ergebnis.nachgeladen, 1);
assert.deepEqual(ergebnis.fehler, []);
assert.equal(ergebnis.uebrig, 0);
const titel = await prisma.title.findFirst({ where: { tmdbId: MOVIE_2_ID } });
assert.ok(titel, "der Titel steht jetzt im Katalog");
const eintrag = await prisma.libraryEntry.findFirstOrThrow({
where: { userId, titleId: titel.id },
select: { status: true, owned: true, ownedSource: true },
});
assert.equal(eintrag.status, "WATCHLIST");
assert.equal(eintrag.owned, true, "was auf dem Server liegt, ist vorhanden");
assert.equal(eintrag.ownedSource, "Jellyfin");
} finally {
jf.restore();
}
});
test("ein einzelner Fehlschlag bricht den Import nicht ab", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
const ergebnis = await importUnassigned(userId, verbindung.id, [
// Diese Kennung kennt der Stub nicht — sie muss scheitern.
{ tmdbId: 424242, typ: "MOVIE" },
{ tmdbId: MOVIE_2_ID, typ: "MOVIE" },
]);
assert.equal(ergebnis.nachgeladen, 1, "der zweite Titel kommt trotzdem an");
assert.equal(ergebnis.fehler.length, 1);
assert.ok(ergebnis.fehler[0]!.includes("424242"));
} finally {
jf.restore();
}
});
test("nachgeladene Titel verschwinden aus der Auswahlliste", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
await syncConnection(userId, verbindung.id);
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
const vorher = await gespeicherterBericht(verbindung.id);
assert.ok(vorher.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242));
await importUnassigned(userId, verbindung.id, [{ tmdbId: MOVIE_2_ID, typ: "MOVIE" }]);
// Der fremde Film wurde nicht ausgewaehlt und bleibt stehen …
const nachher = await gespeicherterBericht(verbindung.id);
assert.ok(nachher.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242));
// Ein Fehlschlag laesst den Eintrag dagegen stehen: TMDB kennt 424242
// nicht, und wer es spaeter erneut versuchen will, soll ihn noch finden.
const misslungen = await importUnassigned(userId, verbindung.id, [
{ tmdbId: 424242, typ: "MOVIE" },
]);
assert.equal(misslungen.nachgeladen, 0);
assert.equal(misslungen.fehler.length, 1);
const zuletzt = await gespeicherterBericht(verbindung.id);
assert.ok(
zuletzt.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242),
"was nicht geholt werden konnte, bleibt in der Liste",
);
} finally {
jf.restore();
}
});
test("fremde Verbindungen lassen sich nicht befuellen", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
const fremderId = (
await prisma.user.create({
data: { email: "test-mediaserver-import@flimmer.invalid", name: "Fremd" },
select: { id: true },
})
).id;
try {
await assert.rejects(
() => importUnassigned(fremderId, verbindung.id, [{ tmdbId: MOVIE_2_ID, typ: "MOVIE" }]),
/gibt es nicht/,
);
} finally {
await prisma.user.delete({ where: { id: fremderId } });
}
} finally {
jf.restore();
}
});
async function gespeicherterBericht(connectionId: string) {
const verbindung = await prisma.mediaServerConnection.findUniqueOrThrow({
where: { id: connectionId },
select: { lastSyncReport: true },
});
return verbindung.lastSyncReport as unknown as {
nichtZugeordnet: { tmdbId: number | null }[];
};
}
+138
View File
@@ -0,0 +1,138 @@
import "./setup";
import assert from "node:assert/strict";
import test, { after, before, beforeEach } from "node:test";
import { prisma } from "../src/lib/db";
import { deletePasskey, listPasskeys, renamePasskey } from "../src/lib/passkeys";
import { env } from "../src/lib/env";
/**
* Prüft die Verwaltung der Passkeys und die Sperre gegen das Aussperren.
*
* Die eigentliche WebAuthn-Prüfung braucht einen echten Authenticator und
* läuft deshalb im Klickweg mit virtuellem Authenticator, nicht hier.
*/
const MIT_PASSWORT = "test-passkey-pw@flimmer.invalid";
const OHNE_PASSWORT = "test-passkey-nopw@flimmer.invalid";
let mitPasswortId: string;
let ohnePasswortId: string;
let zaehler = 0;
async function passkeyAnlegen(userId: string, name: string) {
zaehler += 1;
return prisma.passkey.create({
data: {
userId,
credentialId: `test-credential-${zaehler}-${Date.now()}`,
publicKey: Buffer.from([1, 2, 3]),
name,
},
select: { id: true },
});
}
async function aufraeumen() {
await prisma.user.deleteMany({ where: { email: { in: [MIT_PASSWORT, OHNE_PASSWORT] } } });
}
before(async () => {
await aufraeumen();
mitPasswortId = (
await prisma.user.create({
data: { email: MIT_PASSWORT, name: "Mit Passwort", passwordHash: "$2a$10$platzhalter" },
select: { id: true },
})
).id;
ohnePasswortId = (
await prisma.user.create({
data: { email: OHNE_PASSWORT, name: "Ohne Passwort" },
select: { id: true },
})
).id;
});
beforeEach(async () => {
await prisma.passkey.deleteMany({ where: { userId: { in: [mitPasswortId, ohnePasswortId] } } });
});
after(async () => {
await aufraeumen();
await prisma.$disconnect();
});
test("die RP ID kommt aus APP_URL, nicht aus dem Host-Header", () => {
// Ein Passkey haengt fest an diesem Namen — hinter einem Reverse Proxy waere
// der Host-Header der falsche Ort, ihn herzunehmen.
assert.equal(env.passkeys.rpId, new URL(env.appUrl).hostname);
assert.equal(env.passkeys.rpId, "localhost");
});
test("ueber localhost sind Passkeys erlaubt, ueber nacktes HTTP nicht", () => {
assert.equal(env.passkeys.isAvailable, true, "localhost gilt als sicherer Kontext");
});
test("Passkeys lassen sich auflisten, umbenennen und entfernen", async () => {
const passkey = await passkeyAnlegen(mitPasswortId, "Erstes Geraet");
const liste = await listPasskeys(mitPasswortId);
assert.equal(liste.length, 1);
assert.equal(liste[0]!.name, "Erstes Geraet");
assert.equal(liste[0]!.lastUsedAt, null, "frisch angelegt, noch nicht benutzt");
await renamePasskey(mitPasswortId, passkey.id, " Yubikey am Schluesselbund ");
assert.equal(
(await listPasskeys(mitPasswortId))[0]!.name,
"Yubikey am Schluesselbund",
"Leerraum wird abgeschnitten",
);
await deletePasskey(mitPasswortId, passkey.id);
assert.equal((await listPasskeys(mitPasswortId)).length, 0);
});
test("fremde Passkeys sind unerreichbar", async () => {
const fremd = await passkeyAnlegen(ohnePasswortId, "Fremdes Geraet");
await assert.rejects(() => renamePasskey(mitPasswortId, fremd.id, "Meiner"), /gibt es nicht/);
await assert.rejects(() => deletePasskey(mitPasswortId, fremd.id), /gibt es nicht/);
// Und er steht unveraendert beim Eigentuemer.
const liste = await listPasskeys(ohnePasswortId);
assert.equal(liste.length, 1);
assert.equal(liste[0]!.name, "Fremdes Geraet");
});
test("der letzte Anmeldeweg laesst sich nicht entfernen", async () => {
// Konto ohne Passwort, nur ein Passkey: das Entfernen wuerde aussperren.
const einziger = await passkeyAnlegen(ohnePasswortId, "Einziger Weg");
await assert.rejects(
() => deletePasskey(ohnePasswortId, einziger.id),
/letzter Anmeldeweg/,
"ohne anderen Weg bleibt der Passkey stehen",
);
assert.equal((await listPasskeys(ohnePasswortId)).length, 1);
// Mit einem zweiten Passkey geht es dann.
await passkeyAnlegen(ohnePasswortId, "Zweiter Weg");
await deletePasskey(ohnePasswortId, einziger.id);
assert.equal((await listPasskeys(ohnePasswortId)).length, 1);
});
test("mit Passwort darf auch der einzige Passkey weg", async () => {
const passkey = await passkeyAnlegen(mitPasswortId, "Einziger Passkey");
await deletePasskey(mitPasswortId, passkey.id);
assert.equal((await listPasskeys(mitPasswortId)).length, 0);
});
test("das Loeschen eines Kontos nimmt seine Passkeys mit", async () => {
const email = "test-passkey-weg@flimmer.invalid";
await prisma.user.deleteMany({ where: { email } });
const user = await prisma.user.create({ data: { email, name: "Weg" }, select: { id: true } });
await passkeyAnlegen(user.id, "Verschwindet");
await prisma.user.delete({ where: { id: user.id } });
assert.equal(await prisma.passkey.count({ where: { userId: user.id } }), 0);
});
+71
View File
@@ -12,9 +12,11 @@ import {
addTitle,
createOrder,
createUniverse,
deleteOrder,
getAccess,
getUniverse,
listUniverses,
setDefaultOrder,
setOrderPositions,
shareUniverse,
trashUniverse,
@@ -280,3 +282,72 @@ test("gelöschte Universen verschwinden aus der Übersicht", async () => {
const list = await listUniverses(ownerId);
assert.equal(list.find((item) => item.id === universe.id), undefined);
});
// --- Standardreihenfolge ------------------------------------------------------
test("die Standardreihenfolge lässt sich umstellen und bleibt eindeutig", async () => {
const universe = await createUniverse(ownerId, "Standardwechsel");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
// Frisch angelegt führt die Erscheinungsreihenfolge.
const vorher = await getUniverse(ownerId, universe.id);
assert.equal(vorher?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
await setDefaultOrder(ownerId, order.id);
// Ohne Angabe in der Adresse erscheint jetzt die eigene Reihenfolge.
const nachher = await getUniverse(ownerId, universe.id);
assert.equal(nachher?.activeOrderId, order.id);
const standards = nachher!.orders.filter((o) => o.isDefault);
assert.equal(standards.length, 1, "es steht immer genau ein Standard");
assert.equal(standards[0]!.id, order.id, "und zwar der zuletzt gesetzte");
});
test("die Adresse schlägt die Standardreihenfolge", async () => {
const universe = await createUniverse(ownerId, "Adressvorrang");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
await setDefaultOrder(ownerId, order.id);
const release = (await getUniverse(ownerId, universe.id))!.orders.find(
(o) => o.kind === "RELEASE",
)!;
const view = await getUniverse(ownerId, universe.id, release.id);
assert.equal(view?.activeOrderId, release.id, "die angeforderte gewinnt gegen den Standard");
});
test("wird die Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge", async () => {
const universe = await createUniverse(ownerId, "Rueckfall");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
await setDefaultOrder(ownerId, order.id);
await deleteOrder(ownerId, order.id);
const view = await getUniverse(ownerId, universe.id);
const standards = view!.orders.filter((o) => o.isDefault);
assert.equal(standards.length, 1, "das Universum bleibt nicht ohne Standard zurück");
assert.equal(standards[0]!.kind, "RELEASE");
assert.equal(view?.activeOrderId, standards[0]!.id);
});
test("den Standard setzen darf nur, wer schreiben darf", async () => {
const universe = await createUniverse(ownerId, "Rechteprobe");
await addCollection(ownerId, universe.id, COLLECTION_ID);
const order = await createOrder(ownerId, universe.id, "Chronologisch");
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "READ");
await assert.rejects(() => setDefaultOrder(guestId, order.id), /nur ansehen/);
// Der Standard steht danach unverändert auf der Erscheinungsreihenfolge.
const unveraendert = await getUniverse(ownerId, universe.id);
assert.equal(unveraendert?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
// Mit Schreibrecht geht es dann.
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "WRITE");
await setDefaultOrder(guestId, order.id);
assert.equal((await getUniverse(guestId, universe.id))?.activeOrderId, order.id);
});
+72 -4
View File
@@ -39,6 +39,10 @@ Ablauf: Datenbank sichern → Änderungen holen → Image bauen → neu starten
warten, bis die Instanz wieder antwortet. Die Datenbankmigrationen spielt der
Container beim Start selbst ein.
Geprüft wird zuerst http://127.0.0.1:APP_PORT/api/health. Ist der Port gar
nicht auf dem Host veröffentlicht — üblich hinter einem Reverse Proxy —,
zählt stattdessen der Healthcheck des Containers.
Optionen:
--ohne-backup Keine Sicherung anlegen (nicht empfohlen)
--ohne-pull Nichts aus dem Repository holen, nur neu bauen und starten
@@ -196,31 +200,95 @@ gut "Container laufen."
schritt "Auf die Instanz warten"
info "Die Datenbankmigrationen spielt der Container beim Start selbst ein."
# Der Container meldet seinen Zustand selbst — der HEALTHCHECK im Image fragt
# /api/health von innen ab. Das ist der verlässlichere Weg: hinter einem
# Reverse Proxy ist der App-Port oft gar nicht auf dem Host veröffentlicht,
# und dann sagt ein fehlschlagendes curl nichts über die Instanz aus.
# Gibt immer 0 zurück: die Funktion läuft auch im Fehlerpfad, und unter
# `set -e` dürfte eine Auskunft über den Zustand nicht selbst das Skript
# abbrechen, bevor der Weg zurück ausgegeben wurde.
container_zustand() {
local id
id=$($COMPOSE ps -q app 2>/dev/null || true)
if [ -z "$id" ]; then
printf 'kein-container'
return 0
fi
# Ohne HEALTHCHECK im Image ist .State.Health leer — dann bleibt der
# Laufzustand (running/exited) als grobe Auskunft.
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}ohne-healthcheck:{{.State.Status}}{{end}}' \
"$id" 2>/dev/null || printf 'unbekannt'
return 0
}
GESUND=0
UEBER_CONTAINER=0
for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then
GESUND=1
break
fi
if [ "$(container_zustand)" = "healthy" ]; then
GESUND=1
UEBER_CONTAINER=1
break
fi
sleep 2
done
if [ "$GESUND" -eq 1 ]; then
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true)
gut "Flimmer antwortet auf Port ${PORT}."
leise "$ANTWORT"
if [ "$UEBER_CONTAINER" -eq 1 ]; then
gut "Der Container meldet sich gesund."
leise "Auf Port ${PORT} war nichts zu erreichen — das ist normal, wenn die App"
leise "nur über einen Reverse Proxy erreichbar ist und den Port nicht veröffentlicht."
else
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true)
gut "Flimmer antwortet auf Port ${PORT}."
leise "$ANTWORT"
fi
printf '\n%s✓ Fertig — Version %s%s\n' "$GUT" "$NEUE_VERSION" "$AUS"
if [ "$VORHER_VERSION" != "$NEUE_VERSION" ]; then
info "Was sich geändert hat, steht in CHANGELOG.md."
fi
[ -n "$SICHERUNG" ] && leise "Sicherung: $SICHERUNG"
if [ -n "$SICHERUNG" ]; then
leise "Sicherung: $SICHERUNG"
fi
exit 0
fi
# --- Fehlschlag: konkreter Weg zurück ----------------------------------------
printf '\n%s✗ Die Instanz antwortet nach zwei Minuten nicht.%s\n\n' "$FEHLER" "$AUS" >&2
# Der Container-Zustand grenzt ein, wo es klemmt — ohne ihn sähe ein nicht
# veröffentlichter Port genauso aus wie eine abgestürzte App.
ZUSTAND=$(container_zustand)
case "$ZUSTAND" in
kein-container)
info "Es läuft gar kein app-Container. Der Start ist offenbar fehlgeschlagen."
;;
starting)
info "Der Container startet noch. Möglicherweise dauern die Migrationen länger —"
info "sieh im Log nach und gib ihm etwas Zeit."
;;
unhealthy)
info "Der Container läuft, meldet sich aber als nicht gesund. Der Fehler liegt"
info "also in der Anwendung, nicht am Port."
;;
ohne-healthcheck:running)
info "Der Container läuft, das Image bringt aber keinen Healthcheck mit (ältere"
info "Fassung). Ob die App wirklich antwortet, verrät nur das Log."
;;
ohne-healthcheck:*)
info "Der Container ist nicht gelaufen: ${ZUSTAND#ohne-healthcheck:}"
;;
*)
info "Container-Zustand: ${ZUSTAND}"
;;
esac
printf '\n'
info "Log ansehen:"
printf ' %s logs --tail 50 app\n\n' "$COMPOSE"