4 Commits
Author SHA1 Message Date
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
38 changed files with 2372 additions and 11 deletions
+115
View File
@@ -7,6 +7,121 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
## [0.8.0] — 2026-09-02
Zwei größere Bausteine: **Anmeldung per Passkey** und die **Jellyfin-Anbindung**.
Dazu vier Nacharbeiten aus dem laufenden Betrieb.
> ⚠️ **Beim Aktualisieren kommen zwei Migrationen mit** (`passkeys`,
> `medienserver`). Der Container spielt sie beim Start selbst ein; `./update.sh`
> legt vorher eine Sicherung an.
### Hinzugefügt
- **Anmeldung per Passkey (WebAuthn).** Windows Hello, Face ID, Touch ID oder
ein Sicherheitsschlüssel — statt Passwort. Anlegen und verwalten unter
*Einstellungen → Passkeys*, anmelden über „Mit Passkey anmelden" **ohne
vorherige E-Mail-Eingabe**.
Ein Passkey ist der dritte Weg in denselben Trichter: am Ende steht dieselbe
Sitzung wie bei Passwort und OIDC. Das Passwort bleibt daneben bestehen, und
den letzten verbleibenden Anmeldeweg lässt Flimmer nicht entfernen.
Drei Dinge, die beim Selbsthosten zählen:
- **HTTPS ist Pflicht** (Ausnahme: `localhost`). Läuft die Instanz über
einfaches HTTP, verschwindet die Verwaltung mit einem Hinweis, statt einen
kryptischen Browserfehler zu zeigen.
- Der Hostname kommt aus `APP_URL`, nicht aus dem `Host`-Header — hinter
einem Reverse Proxy hinge er sonst am falschen Namen.
- **Ein Domainwechsel entwertet alle Passkeys.** Sie sind fest an den
Hostnamen gebunden und müssen danach neu angelegt werden.
Abschaltbar über `ENABLE_PASSKEYS`.
- **Jellyfin anbinden** — Bestand und Sichtungen übernehmen, unter
*Einstellungen → Medienserver*.
Zugeordnet wird über die **TMDB-Kennung**, die Jellyfin zu jedem Item
mitliefert; dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut. Kein
Raten über Titel und Jahreszahlen — die Zuordnung ist eindeutig oder sie
findet nicht statt. Was Jellyfin ohne Kennung führt, landet im Bericht.
Angemeldet wird mit Benutzername und Passwort, **nicht** mit einem
API-Schlüssel: mit einem Schlüssel liefert Jellyfin den Bestand ohne
Gesehen-Status, und zwar ohne Fehlermeldung. Der Verbindungstest prüft das
ausdrücklich nach und sagt es. Das Zugangstoken liegt verschlüsselt in der
Datenbank (AES-256-GCM, Schlüssel aus `AUTH_SECRET`), das Passwort wird nicht
gespeichert.
Der Abgleich ist eine **Einbahnstraße**: er ergänzt und löscht nie etwas, das
in Flimmer steht. Fehlende Titel werden nur mit gesetztem Häkchen
aufgenommen — im täglichen Lauf also nie. Danach läuft er im
Hintergrundlauf mit.
*Grenze der Quelle:* Jellyfin kennt `PlayCount` und `LastPlayedDate`, aber
keine Liste einzelner Sichtungen. Ein dreimal gesehener Film kommt deshalb
mit **einem** Termin an, nicht mit drei. Das steht auch in der Anleitung.
Das Zurückschreiben nach Jellyfin (Stufe 3) ist noch nicht dabei; das Feld
liegt im Datenmodell bereit.
- **Standardreihenfolge je Universum.** Welche Reihenfolge beim Öffnen
erscheint, lässt sich jetzt festlegen — wer Schreibrecht hat, setzt sie für
alle. Die Rangfolge bleibt schlicht: Adresse schlägt Standard schlägt erste.
Über die Schnittstelle mit `PATCH /api/v1/universen/{id}`.
### Geändert
- **Rückmeldung beim Tippen** in Suche und Bibliothek. Beim Suchen wird das
Trefferraster gedämpft und auf `aria-busy` gesetzt, der Hinweis „sucht …"
steht jetzt auch in der Bibliothek. Die alten Treffer bleiben sichtbar —
sie verschwinden zu lassen ließe die Liste bei jedem Tastendruck springen.
Die Sammelverzögerung liegt einheitlich bei 250 ms.
- **`update.sh` erkennt jetzt den Container-Zustand.** Schlägt die Abfrage über
den veröffentlichten Port fehl, zählt der Healthcheck des Containers —
hinter einem Reverse Proxy ist der App-Port oft gar nicht auf dem Host
veröffentlicht, und das Skript meldete dann einen Fehlschlag, obwohl das
Update sauber durchgelaufen war. Im Fehlerfall nennt es außerdem den Zustand
und ordnet ihn ein.
*Zur Klarstellung:* Der Changelog zu 0.7.1 führte diese Rückfallprüfung
bereits auf. Sie war dort beschrieben, aber nicht umgesetzt — jetzt ist sie
es.
- Die Entwicklungsdatenbank hat einen eigenen Compose-Projektnamen
(`flimmer-dev`). Vorher leiteten beide Compose-Dateien ihn aus dem
Verzeichnis ab, und ein `docker compose down -v` auf dem Produktivstack nahm
das Volume der Entwicklungsdatenbank mit.
### Behoben
- **Ein als gesehen markierter Film ließ sich nicht mehr zurücksetzen.** Im
Zustand „gesehen" stand statt eines Knopfes ein Etikett ohne Klickziel, und
eine Gegenfunktion gab es nicht — Serien hatten längst einen Umschalter,
Filme nur einen Hinweg. Die grüne Plakette ist jetzt klickbar.
Zurückgenommen wird dabei **nur der laufende Durchlauf**: wer einen Film
dreimal gesehen hat, verliert die früheren Sichtungen nicht.
- **Die Beschriftungen im Titelraster sprangen**, sobald ein Titel zweizeilig
umbrach — die Metazeile saß dann eine Zeilenhöhe tiefer als bei den Nachbarn,
und die Reihen waren unterschiedlich hoch. Der Titelblock belegt jetzt immer
zwei Zeilen; in der Bibliothek wird zusätzlich der Platz des
Fortschrittsbalkens freigehalten, den es nur bei Serien gibt.
### Getestet
78 Tests (vorher 52). Neu dazu: Passkey-Verwaltung samt Aussperrsperre, der
Jellyfin-Abgleich gegen nachgebildete Antworten, die Standardreihenfolge samt
Eindeutigkeit, und das Zurücknehmen einer Filmsichtung über
Durchlaufgrenzen hinweg.
Zusätzlich wurden die Klickwege gegen einen **Produktionsbau** geprüft — die
Passkey-Anmeldung mit einem virtuellen Authenticator, der Jellyfin-Abgleich
gegen einen nachgebildeten Server samt des Falls, in dem der Gesehen-Status
fehlt.
## [0.7.1] — 2026-09-01
### Hinzugefügt
+24 -8
View File
@@ -112,6 +112,13 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
**Alle sechs Etappen abgeschlossen. Gesamtzeit: 2 h 16 min.**
- ✅ **Nacharbeiten** (v0.7.0/0.7.1) — Docker verifiziert, „hab ich", `update.sh`. 51 min.
- ✅ **Issue-Runde** (v0.8.0) — Passkeys, Jellyfin-Anbindung, Standardreihenfolge,
Rückmeldung beim Tippen, Titelraster, Filmsichtung zurücknehmen. 77 min.
Ab v0.8.0 wird **issue-getrieben** gearbeitet: eigener Branch je Issue,
Zeiterfassung in Gitea, Freigabe durch Florian. Details im DEVLOG.
### Nützliches für spätere Arbeiten
- Suche ohne TMDB-Schlüssel ausprobieren:
`npm run tmdb:stub` und dann
@@ -122,16 +129,25 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok.
- Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das
Write-Werkzeug benutzen.
- **Klickwege niemals gegen `npm run dev` prüfen.** Unter headless Chromium
scheitert der HMR-Socket (`ERR_INVALID_HTTP_RESPONSE`), dadurch hydratisiert
**keine** Seite und kein Knopf reagiert — ohne Seitenfehler, ohne
fehlgeschlagene Anfrage, es passiert einfach nichts. Sieht aus wie ein Fehler
im eigenen Code, ist aber die Umgebung. Stattdessen
`npm run build` und `PORT=3012 npm start`, dann dagegen klicken.
Formulare täuschen zusätzlich: die Anmeldung funktioniert auch ohne
JavaScript, wirkt also intakt.
- **Wenn Klicks unter Playwright wirkungslos bleiben, liegt es meist nicht am
eigenen Code.** Beobachtet: gegen `npm run dev` reagierte unter headless
Chromium **keine** Seite — kein Knopf, nicht einmal der Theme-Umschalter,
ohne Seitenfehler und ohne fehlgeschlagene Anfrage. Begleitet vom
scheiternden HMR-Socket (`ERR_INVALID_HTTP_RESPONSE`).
**Der Zustand ist aber nicht dauerhaft:** in einem späteren Lauf gegen
denselben Dev-Server funktionierten dieselben Klicks. Der HMR-Fehler tritt
in beiden Fällen auf, taugt also nicht als Erklärung.
Vorgehen: erst mit `Object.keys(el).some(k => k.startsWith("__react"))`
prüfen, ob überhaupt hydratisiert wurde. Bleibt es dabei, gegen einen
Produktionsbau prüfen (`npm run build`, `PORT=3012 npm start`) — dort trat
es nie auf. Formulare täuschen zusätzlich: die Anmeldung funktioniert auch
ohne JavaScript und wirkt deshalb intakt.
- Vor `npm start` prüfen, ob der Port noch von einem früheren Lauf belegt ist —
sonst startet der neue Server gar nicht und man testet den alten Bau.
- **`APP_URL` muss zum tatsächlich aufgerufenen Port passen.** In der `.env`
steht `http://localhost:3010`; wer gegen 3011 oder 3012 testet, muss
`APP_URL` mitgeben. Passkeys scheitern sonst am Ursprungsabgleich (500),
und der OIDC-Rücksprung zeigt auf die falsche Adresse.
- Testdaten nach Klickproben wieder herstellen: die Proben schreiben in die
geseedete Entwicklungsdatenbank.
+45 -1
View File
@@ -103,6 +103,49 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
---
## Issue-Runde (v0.8.0)
Ab hier lief die Arbeit issue-getrieben: jede Aufgabe mit eigenem Branch,
Zeiterfassung in Gitea und Freigabe durch Florian. Die Zeiten unten sind die
gemessenen Stopwatch-Werte, nicht die Wanduhr — dazwischen lagen Pausen.
| # | Issue | Dauer | Was passiert ist |
|---|---|---|---|
| 8.1 | #2 Titelraster | 6 min | Titelblock auf zwei Zeilen festgenagelt; vorher und nachher vermessen |
| 8.2 | #4 Standardreihenfolge | 15 min | Feld war da und wurde ausgewertet — es fehlte nur der Weg, es zu setzen |
| 8.3 | #5 Gesehen zurücknehmen | 10 min | Der Knopf war ein `<p>`; die Gegenfunktion für Filme fehlte ganz |
| 8.4 | #3 Träge Suche | 7 min | Das Ladesignal wurde erzeugt und weggeworfen; zwei Verdächtige durch Messung entlastet |
| 8.5 | #6 Passkeys | 20 min | WebAuthn als dritter Weg in dieselbe Sitzung, geprüft mit virtuellem Authenticator |
| 8.6 | #1 Jellyfin | 19 min | Bestand und Sichtungen über die TMDB-Kennung, Stufe 1 und 2 |
**Issues gesamt: 1 h 17 min** (2026-09-01 21:22 – 2026-09-02 07:25)
Dazu ohne Zeiterfassung: die Nacharbeit an `update.sh` (Rückfall auf den
Container-Healthcheck), die Merkposten in `CLAUDE.md` und diese Version
selbst — Changelog, Screenshots, Release.
### Was dabei auffiel
1. **Zwei eigene Vermutungen hielten der Messung nicht stand.** Bei #3 hatte
ich Facetten-Abfragen und blockierende Poster als Ursachen benannt. Gemessen
an 2000 Einträgen kosten die Facetten 12 ms, und bei künstlich um 1500 ms
verzögerten Bildern stehen die Treffer 891 ms vor den Bildern im DOM. Beide
blieben deshalb unangetastet.
2. **Ein Nebenpunkt in einem Issue war schlicht falsch.** Ich hatte bei #5
behauptet, die Einblendung biete ein wirkungsloses Zurücknehmen an — sie
prüft aber längst auf `ids.length > 0`.
3. **Der Changelog zu 0.7.1 beschrieb eine Funktion, die es nicht gab.** Die
Rückfallprüfung über den Container-Healthcheck stand dort, war aber nie
umgesetzt. Jetzt ist sie es.
4. **Ein Abgleich mit null Treffern war kein Fehler.** Der erste Jellyfin-Lauf
gegen die Beispieldaten übernahm keine einzige Sichtung — korrektes
Entdoppeln, die Episoden waren im Seed längst abgehakt.
5. **Sitzungen über nacktes HTTP gehen nicht.** Das Cookie ist in der
Produktion `Secure`; auf einer LAN-IP kommt man deshalb gar nicht herein,
ohne Fehlermeldung. Nicht behoben, festgehalten.
---
## Gesamtübersicht
| Etappe | Version | Dauer |
@@ -114,8 +157,9 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
| Issue-Runde — Passkeys, Jellyfin, vier Nacharbeiten | 0.8.0 | 77 min |
**Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16)
**Gesamtentwicklungszeit: 4 h 24 min** (264 min, 2026-09-01 18:09 – 2026-09-02 07:25)
### Womit die Zeit verging
+85
View File
@@ -256,6 +256,40 @@ Geräten abgemeldet.
---
## Anmelden per Passkey
![Passkeys](screenshots/21-passkeys.webp)
Statt Passwort geht auch ein **Passkey** — Windows Hello, Face ID, Touch ID
oder ein Sicherheitsschlüssel am Schlüsselbund. Auf dem Handy ist das der
Unterschied zwischen einem Blick in die Kamera und dem Abtippen eines langen
Passworts.
Unter **Einstellungen → Passkeys** gibst du dem Gerät einen Namen und klickst
**Passkey anlegen**; den Rest übernimmt dein Browser. Danach steht auf der
Anmeldeseite „Mit Passkey anmelden" — **ohne** dass du vorher deine
E-Mail-Adresse eintippen musst.
Das Passwort bleibt daneben bestehen. Den **letzten verbleibenden Anmeldeweg**
lässt Flimmer nicht entfernen: wer weder Passwort noch SSO hat, behält seinen
letzten Passkey.
> **Passkeys brauchen HTTPS**
> Der Browser gibt sie nur über eine verschlüsselte Verbindung frei; einzige
> Ausnahme ist `localhost`. Läuft deine Instanz über einfaches HTTP,
> verschwindet die Verwaltung mit einem Hinweis, statt einen kryptischen
> Browserfehler zu zeigen.
>
> Und: Ein Passkey ist fest an den **Hostnamen** gebunden. Ziehst du von
> `flimmer.fritz.box` auf `flimmer.example.com` um, musst du alle Passkeys neu
> anlegen. Halte vorher einen zweiten Anmeldeweg bereit.
Ein Passkey, der nur auf einem Gerät liegt und nicht mit deinem Konto
synchronisiert wird, ist mit dem Gerät verloren. Die Übersicht sagt dir zu
jedem Eintrag, was Sache ist.
---
## Benachrichtigungen
![Benachrichtigungen](screenshots/12-benachrichtigungen.webp)
@@ -299,6 +333,57 @@ 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.
**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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flimmer",
"version": "0.7.1",
"version": "0.8.0",
"private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT",
@@ -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;
+81 -1
View File
@@ -54,6 +54,7 @@ model User {
apiKeys ApiKey[]
oidcAccounts OidcAccount[]
passkeys Passkey[]
mediaServers MediaServerConnection[]
entries LibraryEntry[]
watches Watch[]
tags Tag[]
@@ -207,6 +208,7 @@ model Title {
watches Watch[]
providers TitleProvider[]
universeItems UniverseItem[]
serverItems MediaServerItem[]
@@unique([type, tmdbId])
@@index([type])
@@ -250,7 +252,8 @@ model Episode {
airDate DateTime?
runtime Int?
watches Watch[]
watches Watch[]
serverItems MediaServerItem[]
@@unique([titleId, seasonNumber, episodeNumber])
@@index([titleId])
@@ -391,6 +394,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)
@@ -548,3 +556,75 @@ 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])
}
+113
View File
@@ -0,0 +1,113 @@
# Flimmer 0.8.0
Zwei größere Bausteine: **Anmeldung per Passkey** und die **Jellyfin-Anbindung**.
Dazu vier Nacharbeiten aus dem laufenden Betrieb.
> ⚠️ **Beim Aktualisieren kommen zwei Migrationen mit** (`passkeys`,
> `medienserver`). Der Container spielt sie beim Start selbst ein; `./update.sh`
> legt vorher eine Sicherung an.
### Hinzugefügt
- **Anmeldung per Passkey (WebAuthn).** Windows Hello, Face ID, Touch ID oder
ein Sicherheitsschlüssel — statt Passwort. Anlegen und verwalten unter
*Einstellungen → Passkeys*, anmelden über „Mit Passkey anmelden" **ohne
vorherige E-Mail-Eingabe**.
Ein Passkey ist der dritte Weg in denselben Trichter: am Ende steht dieselbe
Sitzung wie bei Passwort und OIDC. Das Passwort bleibt daneben bestehen, und
den letzten verbleibenden Anmeldeweg lässt Flimmer nicht entfernen.
Drei Dinge, die beim Selbsthosten zählen:
- **HTTPS ist Pflicht** (Ausnahme: `localhost`). Läuft die Instanz über
einfaches HTTP, verschwindet die Verwaltung mit einem Hinweis, statt einen
kryptischen Browserfehler zu zeigen.
- Der Hostname kommt aus `APP_URL`, nicht aus dem `Host`-Header — hinter
einem Reverse Proxy hinge er sonst am falschen Namen.
- **Ein Domainwechsel entwertet alle Passkeys.** Sie sind fest an den
Hostnamen gebunden und müssen danach neu angelegt werden.
Abschaltbar über `ENABLE_PASSKEYS`.
- **Jellyfin anbinden** — Bestand und Sichtungen übernehmen, unter
*Einstellungen → Medienserver*.
Zugeordnet wird über die **TMDB-Kennung**, die Jellyfin zu jedem Item
mitliefert; dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut. Kein
Raten über Titel und Jahreszahlen — die Zuordnung ist eindeutig oder sie
findet nicht statt. Was Jellyfin ohne Kennung führt, landet im Bericht.
Angemeldet wird mit Benutzername und Passwort, **nicht** mit einem
API-Schlüssel: mit einem Schlüssel liefert Jellyfin den Bestand ohne
Gesehen-Status, und zwar ohne Fehlermeldung. Der Verbindungstest prüft das
ausdrücklich nach und sagt es. Das Zugangstoken liegt verschlüsselt in der
Datenbank (AES-256-GCM, Schlüssel aus `AUTH_SECRET`), das Passwort wird nicht
gespeichert.
Der Abgleich ist eine **Einbahnstraße**: er ergänzt und löscht nie etwas, das
in Flimmer steht. Fehlende Titel werden nur mit gesetztem Häkchen
aufgenommen — im täglichen Lauf also nie. Danach läuft er im
Hintergrundlauf mit.
*Grenze der Quelle:* Jellyfin kennt `PlayCount` und `LastPlayedDate`, aber
keine Liste einzelner Sichtungen. Ein dreimal gesehener Film kommt deshalb
mit **einem** Termin an, nicht mit drei. Das steht auch in der Anleitung.
Das Zurückschreiben nach Jellyfin (Stufe 3) ist noch nicht dabei; das Feld
liegt im Datenmodell bereit.
- **Standardreihenfolge je Universum.** Welche Reihenfolge beim Öffnen
erscheint, lässt sich jetzt festlegen — wer Schreibrecht hat, setzt sie für
alle. Die Rangfolge bleibt schlicht: Adresse schlägt Standard schlägt erste.
Über die Schnittstelle mit `PATCH /api/v1/universen/{id}`.
### Geändert
- **Rückmeldung beim Tippen** in Suche und Bibliothek. Beim Suchen wird das
Trefferraster gedämpft und auf `aria-busy` gesetzt, der Hinweis „sucht …"
steht jetzt auch in der Bibliothek. Die alten Treffer bleiben sichtbar —
sie verschwinden zu lassen ließe die Liste bei jedem Tastendruck springen.
Die Sammelverzögerung liegt einheitlich bei 250 ms.
- **`update.sh` erkennt jetzt den Container-Zustand.** Schlägt die Abfrage über
den veröffentlichten Port fehl, zählt der Healthcheck des Containers —
hinter einem Reverse Proxy ist der App-Port oft gar nicht auf dem Host
veröffentlicht, und das Skript meldete dann einen Fehlschlag, obwohl das
Update sauber durchgelaufen war. Im Fehlerfall nennt es außerdem den Zustand
und ordnet ihn ein.
*Zur Klarstellung:* Der Changelog zu 0.7.1 führte diese Rückfallprüfung
bereits auf. Sie war dort beschrieben, aber nicht umgesetzt — jetzt ist sie
es.
- Die Entwicklungsdatenbank hat einen eigenen Compose-Projektnamen
(`flimmer-dev`). Vorher leiteten beide Compose-Dateien ihn aus dem
Verzeichnis ab, und ein `docker compose down -v` auf dem Produktivstack nahm
das Volume der Entwicklungsdatenbank mit.
### Behoben
- **Ein als gesehen markierter Film ließ sich nicht mehr zurücksetzen.** Im
Zustand „gesehen" stand statt eines Knopfes ein Etikett ohne Klickziel, und
eine Gegenfunktion gab es nicht — Serien hatten längst einen Umschalter,
Filme nur einen Hinweg. Die grüne Plakette ist jetzt klickbar.
Zurückgenommen wird dabei **nur der laufende Durchlauf**: wer einen Film
dreimal gesehen hat, verliert die früheren Sichtungen nicht.
- **Die Beschriftungen im Titelraster sprangen**, sobald ein Titel zweizeilig
umbrach — die Metazeile saß dann eine Zeilenhöhe tiefer als bei den Nachbarn,
und die Reihen waren unterschiedlich hoch. Der Titelblock belegt jetzt immer
zwei Zeilen; in der Bibliothek wird zusätzlich der Platz des
Fortschrittsbalkens freigehalten, den es nur bei Serien gibt.
### Getestet
78 Tests (vorher 52). Neu dazu: Passkey-Verwaltung samt Aussperrsperre, der
Jellyfin-Abgleich gegen nachgebildete Antworten, die Standardreihenfolge samt
Eindeutigkeit, und das Zurücknehmen einer Filmsichtung über
Durchlaufgrenzen hinweg.
Zusätzlich wurden die Klickwege gegen einen **Produktionsbau** geprüft — die
Passkey-Anmeldung mit einem virtuellen Authenticator, der Jellyfin-Abgleich
gegen einen nachgebildeten Server samt des Falls, in dem der Gesehen-Status
fehlt.
+121
View File
@@ -1,5 +1,6 @@
import { chromium } from "playwright";
import fs from "node:fs";
import { createServer } from "node:http";
const OUT = "docs/screenshots";
const BASE = "http://localhost:3011";
@@ -32,6 +33,68 @@ async function login(page) {
await Promise.all([page.waitForURL(`${BASE}/`), page.click("button[type=submit]")]);
}
/** Screenshot eines einzelnen Abschnitts statt der ganzen Seite. */
async function ausschnitt(page, name, ueberschrift) {
// Einblendungen wegklicken — sie legen sich sonst über den Inhalt.
const schliessen = page.locator("[aria-live] button");
for (let i = await schliessen.count(); i > 0; i -= 1) {
await schliessen.first().click().catch(() => {});
await page.waitForTimeout(150);
}
await page.waitForTimeout(400);
const bereich = page.locator("section", { hasText: ueberschrift }).first();
await bereich.screenshot({ path: `${OUT}/${name}.png` });
console.log(" ", name);
}
/**
* Ein Jellyfin, das gerade genug kann: anmelden, Systeminfo, Bestand.
* Die TMDB-Kennungen zeigen auf Titel aus dem Seed, damit der Bericht echte
* Zuordnungen zeigt. Alles auf ungesehen — der Screenshot soll den Bestand
* zeigen, nicht die Sichtungen der Beispieldaten verbiegen.
*/
async function starteJellyfinNachbau() {
const items = [
{ Id: "jf-1", Name: "Die Stille zwischen den Bildern", Type: "Movie",
ProviderIds: { Tmdb: "9100013" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
{ Id: "jf-2", Name: "Die Stille zwischen den Bildern II", Type: "Movie",
ProviderIds: { Tmdb: "9100014" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
{ Id: "jf-3", Name: "Die Vermessung des Feierabends", Type: "Movie",
ProviderIds: { Tmdb: "9100017" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
{ Id: "jf-4", Name: "Urlaubsvideo 2019", Type: "Movie",
ProviderIds: {}, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
];
const server = createServer((req, res) => {
const url = new URL(req.url, "http://x");
const json = (body) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (url.pathname === "/Users/AuthenticateByName") {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () =>
json({ AccessToken: "screenshot-token", User: { Id: "jf-user", Name: "florian" } }),
);
return;
}
if (url.pathname === "/System/Info") return json({ ServerName: "Heimserver", Version: "10.10.3" });
if (url.pathname === "/Items") return json({ Items: items, TotalRecordCount: items.length });
res.writeHead(404);
res.end();
});
await new Promise((fertig) => server.listen(0, "127.0.0.1", fertig));
const port = server.address().port;
return {
url: `http://127.0.0.1:${port}`,
close: () => new Promise((fertig) => server.close(fertig)),
};
}
async function shot(page, name, url, opts = {}) {
await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" });
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
@@ -78,6 +141,64 @@ async function shot(page, name, url, opts = {}) {
await ctx.close();
}
// --- Zugang: Passkeys und Medienserver ---
//
// Beide brauchen Gegenstellen: einen Authenticator und einen Jellyfin-Server.
// Statt echte Geräte vorauszusetzen, stellt das Skript sie selbst bereit —
// der virtuelle Authenticator kommt vom Browser, der Medienserver ist ein
// paar Zeilen HTTP hier drin. Beide Spuren werden am Ende wieder entfernt.
{
const jellyfin = await starteJellyfinNachbau();
const { ctx, page } = await session(1400, 1000, "dark");
const cdp = await ctx.newCDPSession(page);
await cdp.send("WebAuthn.enable");
await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
await login(page);
await page.goto(`${BASE}/einstellungen`, { waitUntil: "networkidle" });
// Passkey anlegen, damit die Liste nicht leer ist.
await page.fill("input[aria-label='Name für den neuen Passkey']", "Mein Notebook");
await page.getByRole("button", { name: "Passkey anlegen" }).click();
await page.waitForTimeout(2500);
await ausschnitt(page, "21-passkeys", "Passkeys");
// Jellyfin verbinden und einmal abgleichen.
const medien = page.locator("section", { hasText: "Medienserver" });
await page.fill("input[placeholder='http://jellyfin.fritz.box:8096']", jellyfin.url);
await medien.locator("input").nth(1).fill("florian");
await medien.locator("input").nth(2).fill("geheim");
await page.getByRole("button", { name: "Verbinden" }).click();
await page.waitForTimeout(2500);
await page.getByRole("button", { name: "Jetzt abgleichen" }).click();
await page.waitForTimeout(3500);
await ausschnitt(page, "22-medienserver", "Medienserver");
// Spuren beseitigen: erst die Verbindung, dann den Passkey.
await page.getByRole("button", { name: "Entfernen" }).first().click();
await page.waitForTimeout(1500);
await page.reload({ waitUntil: "networkidle" });
const passkeyWeg = page.locator("section", { hasText: "Passkeys" }).getByRole("button", { name: "Entfernen" });
if (await passkeyWeg.count()) {
await passkeyWeg.first().click();
await page.waitForTimeout(1500);
}
await ctx.close();
await jellyfin.close();
}
// --- Hell ---
{
const { ctx, page } = await session(1400, 900, "light");
+114
View File
@@ -15,6 +15,14 @@ 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,
syncConnection,
testConnection,
updateConnection,
type SyncReport,
} from "@/lib/mediaserver";
/** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */
@@ -304,3 +312,109 @@ 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.",
};
}
}
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" };
});
}
+23
View File
@@ -5,6 +5,8 @@ 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";
@@ -32,6 +34,7 @@ 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">
@@ -70,6 +73,26 @@ export default async function SettingsPage() {
</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,
}))}
/>
</section>
<p className="text-xs text-[var(--fl-faint)]">
Flimmer {pkg.version} · Instanz {settings.instanceName}
</p>
+326
View File
@@ -0,0 +1,326 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
connectMediaServerAction,
deleteMediaServerAction,
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 Verbindung = {
id: string;
name: string;
baseUrl: string;
serverUserName: string;
enabled: boolean;
syncOwned: boolean;
syncWatched: boolean;
lastSyncAt: string | null;
};
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="Fehlende Titel dabei aufnehmen"
checked={aufnehmen}
onChange={setAufnehmen}
/>
</div>
</li>
))}
</ul>
)}
{pruefung && <Pruefbericht pruefung={pruefung} />}
{bericht && <Abgleichbericht bericht={bericht} />}
{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>
);
}
+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");
}
+459
View File
@@ -0,0 +1,459 @@
import "server-only";
import { prisma } from "./db";
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 {}
export type SyncReport = {
gelesen: number;
bestandGesetzt: number;
sichtungenAngelegt: number;
aufgenommen: number;
nichtZugeordnet: { name: string; grund: string }[];
fehler: string[];
};
const LEERER_BERICHT = (): SyncReport => ({
gelesen: 0,
bestandGesetzt: 0,
sichtungenAngelegt: 0,
aufgenommen: 0,
nichtZugeordnet: [],
fehler: [],
});
/** Wie viele unzuordenbare Titel im Bericht landen — der Rest wäre nur Lärm. */
const BERICHT_GRENZE = 50;
// ---------------------------------------------------------------------------
// 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]));
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, item, "keine TMDB-Kennung am Item");
continue;
}
const treffer = titelNachTmdb.get(tmdbId);
if (!treffer) {
merkeUnzugeordnet(bericht, item, `TMDB ${tmdbId} 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,
);
}
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;
}
function merkeUnzugeordnet(bericht: SyncReport, item: JellyfinItem, grund: string) {
if (bericht.nichtZugeordnet.length >= BERICHT_GRENZE) return;
const name =
item.Type === "Episode" && item.SeriesName
? `${item.SeriesName} — ${item.Name}`
: (item.Name ?? "Ohne Titel");
bericht.nichtZugeordnet.push({ name, grund });
}
/**
* 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;
}
/**
* 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 };
}
+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");
}
+20
View File
@@ -3,6 +3,7 @@ import { prisma } from "./db";
import { env } from "./env";
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
import { notifyUser } from "./notify";
import { syncAllConnections } from "./mediaserver";
import { episodeCode } from "./labels";
/**
@@ -26,6 +27,8 @@ export type SyncStats = {
notificationsSent: number;
entriesPurged: number;
sessionsPurged: number;
mediaServersSynced: number;
mediaServerWatches: number;
errors: string[];
};
@@ -232,6 +235,8 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
notificationsSent: 0,
entriesPurged: 0,
sessionsPurged: 0,
mediaServersSynced: 0,
mediaServerWatches: 0,
errors: [],
};
@@ -283,6 +288,21 @@ 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"}`,
);
}
const purged = await purgeExpired();
stats.entriesPurged = purged.entries;
stats.sessionsPurged = purged.sessions;
+161
View File
@@ -0,0 +1,161 @@
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 },
},
} 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;
},
};
}
+317
View File
@@ -0,0 +1,317 @@
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,
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, 6, "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",
);
} 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();
}
});