54 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
Florian Wilde 19f8003945 feat: update.sh für den Betrieb unter Linux
Release / veroeffentlichen (push) Canceled after 0s
CI / pruefen (push) Canceled after 0s
Erst sichern, dann anfassen: schlägt die Sicherung fehl, bricht das Skript ab
statt weiterzumachen. Geht danach etwas schief, nennt es den konkreten Weg
zurück — mit dem Commit, zu dem man zurückspringt, und der Datei, die man
einspielt, nicht bloß dem Rat, ein Backup zu haben.

Lokale Änderungen im Arbeitsverzeichnis werden erkannt und nicht überfahren.
Die Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
nicht nach außen geht, über den Healthcheck des Containers.

Das Ausführbar-Bit steckt im Repository — nach dem Klonen ist es startklar.
2026-09-01 22:46:36 +02:00
Florian Wilde 9ee8664233 feat: eigene Sammlung — „hab ich" mit Quelle
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s
Streaming-Verfügbarkeit beantwortet nur die halbe Frage: manche Filme laufen
nirgends mehr, die hat man gekauft und liegen zu Hause — in Jellyfin, Plex,
Emby, auf dem NAS oder als Scheibe.

Bewusst getrennt vom Feld „wo schaue ich das": das eine sagt, wo ein Titel
gerade läuft, und das ändert sich ständig; das andere sagt, was einem gehört,
und das bleibt. Auf der Detailseite steht die Sammlung deshalb vor der
Verfügbarkeit — was man hat, ist die verlässlichere Antwort.

Durchgezogen bis in Filter, Export, CSV-Import und REST-API.
2026-09-01 21:15:53 +02:00
Florian Wilde cd9953c47c chore: ueberfluessiges @types/bcryptjs entfernen
bcryptjs bringt eigene Typdefinitionen mit; das Stub-Paket war nur noch eine
Deprecation-Warnung beim Installieren.
2026-09-01 20:59:31 +02:00
Florian Wilde 18c87e6b2e fix: Schrift mitliefern statt beim Bauen herunterladen
next/font/google laedt Outfit beim Bauen von Google — das macht den Build von
einer Netzverbindung abhaengig und liess ihn im Container haengen. Die
variable Fassung liegt jetzt als Datei im Repository; weder beim Bauen noch
zur Laufzeit geht eine Anfrage nach aussen.

Ausserdem: npm blockiert seit Version 11 Install-Skripte. Fuer prisma und
@prisma/engines sind sie jetzt gezielt erlaubt, damit im Image die
plattformpassende Engine bereitliegt.
2026-09-01 20:56:32 +02:00
Florian Wilde afa3d74248 docs: Changelog und Release-Notes fuer 0.6.1 vorbereiten
CI / pruefen (push) Canceled after 0s
2026-09-01 20:52:52 +02:00
Florian Wilde b3eb946cfa docs: Diagrammfarben in der Design-Dokumentation festhalten 2026-09-01 20:51:11 +02:00
Florian Wilde 29ef710b9b docs: CI-Ablaeufe in der README erwaehnen 2026-09-01 20:50:47 +02:00
Florian Wilde 3db6241e9a chore: ungenutzte Bildkonfiguration entfernen
next/image kommt nirgends zum Einsatz — Bilder laufen ueber den eigenen
Cache-Endpunkt. Damit entfaellt auch das sicherheitsrelevant aussehende
dangerouslyAllowSVG, das ohnehin wirkungslos war.
2026-09-01 20:50:18 +02:00
Florian Wilde d8239441aa ci: Workflows für Gitea Actions
CI prüft bei jedem Push Typen, Tests und den Produktionsbuild — die Tests
laufen gegen eine echte PostgreSQL-Instanz, weil sie Abfragen und
Fortschrittsberechnungen prüfen, nicht Attrappen.

Der Release-Workflow zieht die Notizen aus releases/vX.Y.Z.md, derselben
Datei, die auch von Hand für ein Release taugt. So gibt es eine Quelle für
den Text, und der Ablauf funktioniert mit und ohne CI.
2026-09-01 20:50:17 +02:00
Florian Wilde 96a42594a9 feat: Erscheinungsmeldung für Watchlist-Filme
Der Auftrag nennt sie ausdrücklich, das Profil hatte den Schalter schon —
verschickt wurde bisher nichts.

Betrachtet wird ein Zeitfenster der letzten Tage statt eines Zustandswechsels:
TMDB verschiebt Termine gelegentlich, und ein einmal verpasstes Datum soll
nicht dazu führen, dass die Meldung für immer ausbleibt. Doppelt kommt sie
trotzdem nie.

Die Meldung hängt nicht am TMDB-Abruf, sondern an den Erscheinungsdaten, die
ohnehin im Katalog stehen — sie funktioniert also auch ohne Schlüssel.
2026-09-01 20:50:17 +02:00
Florian Wilde a54e625ca8 docs: oeffentliches Profil in der Anleitung ergaenzen 2026-09-01 20:37:58 +02:00
Florian Wilde ff9d380d42 docs: Changelog, Release-Notes und DEVLOG fuer 0.6.0 2026-09-01 20:25:13 +02:00
Florian Wilde 8d6f1d044a docs: bebilderte Anleitung, API-Dokumentation und Screenshots
docs/ANLEITUNG.md führt einmal durch die ganze Anwendung — von der ersten
Anmeldung über das tägliche Abhaken bis zu Universen, Statistik und
Verwaltung, mit 18 Aufnahmen aus der laufenden Instanz.

Die Bilder entstehen mit npm run screenshots: aufgenommen mit doppelter
Pixeldichte für scharfe Schrift, abgelegt als WebP in halber Kantenlänge —
13 MB werden so zu 0,6 MB.
2026-09-01 20:25:13 +02:00
Florian Wilde cf38bd6854 feat: TMDB-Adresse konfigurierbar und lokaler Ersatz für die Suche
TMDB_BASE_URL hilft Instanzen hinter einem eigenen Proxy — und erlaubt es,
die Suche ohne Schlüssel aus den Beispieldaten zu bedienen. Damit lässt sich
die Oberfläche ausprobieren, bevor man einen Schlüssel besorgt, und die
Screenshots der Anleitung zeigen eine Suche, die tatsächlich etwas findet.
2026-09-01 20:25:12 +02:00
Florian Wilde 0adcccc01a test: Import, Export, Papierkorb und Doppelversandschutz absichern
Neun Tests: CSV mit Komma und Semikolon, Anführungszeichen und Zeilenumbrüchen,
Zuordnung zu vorhandenen Katalogtiteln, Trakt-Formen, Export in beiden
Formaten, Aufbewahrungsfrist des Papierkorbs und der Schutz davor, dieselbe
Meldung zweimal zu verschicken.
2026-09-01 20:25:12 +02:00
Florian Wilde f02e3597b9 feat: PWA-Feinschliff mit Service Worker und Offline-Seite
Drei Strategien: Poster und statische Dateien zuerst aus dem Zwischenspeicher,
Seiten zuerst aus dem Netz, API-Aufrufe gar nicht.

Ein Tracker, der veraltete Stände zeigt, wäre schlimmer als einer, der ehrlich
sagt: gerade keine Verbindung. Schreibende Anfragen laufen nie über den
Service Worker.
2026-09-01 20:25:11 +02:00
Florian Wilde a9314acbb4 feat: Einstellungen, Verwaltung und öffentliches Profil
Nach einem Passwortwechsel fliegen alle anderen Geräte raus; ein gesperrtes
Konto wird sofort abgemeldet. Die letzte Administration lässt sich nicht
entmachten, und sich selbst kann niemand sperren.

Das öffentliche Profil zeigt Zahlen und zuletzt Gesehenes — keine Notizen,
keine Watchlist, keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat.
Es braucht die Freigabe des Nutzers und die des Betreibers.
2026-09-01 20:25:11 +02:00
Florian Wilde bb63827bf7 feat: REST-API unter /api/v1 mit OpenAPI-Beschreibung
Dieselben Endpunkte bedienen Skripte und das eigene Frontend: erst wird ein
Bearer-Token geprüft, sonst das Sitzungscookie.

Sichtungsaktionen geben immer watchIds zurück — damit lässt sich genau diese
Aktion über DELETE zurücknehmen, ohne raten zu müssen, was dazugehörte.

Die OpenAPI-Beschreibung ist handgeschrieben statt aus Zod erzeugt: die
Schnittstelle ist überschaubar, und ein Generator brächte eine weitere
Abhängigkeit für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
2026-09-01 20:24:48 +02:00
Florian Wilde b25f0473fd feat: Import aus Trakt und CSV, Export als CSV und JSON
Grundhaltung beim Import: nichts überschreiben. Was schon in der Bibliothek
liegt, bleibt wie es ist; es kommt nur dazu, was fehlt. Ein Import soll
niemand versehentlich seinen Fortschritt kosten.

Trakt gibt je nach Ansicht unterschiedliche Formen aus. Gemeinsam ist ihnen
ein Array mit Einträgen, die entweder show oder movie tragen — genau darauf
stützt sich der Import, statt ein bestimmtes Exportformat zu verlangen.

Der CSV-Leser errät das Trennzeichen: deutsche Tabellenprogramme nehmen gern
das Semikolon.
2026-09-01 20:24:47 +02:00
Florian Wilde 969353ab32 feat: täglicher Abgleich mit TMDB
Frischt laufende Serien auf, meldet neue Episoden und neu verfügbare
Watchlist-Titel, räumt Papierkorb und abgelaufene Sitzungen.

Grundsatz: Der Lauf darf nie an einem einzelnen Titel scheitern. Fehler werden
gezählt und protokolliert, der Lauf macht weiter.

Neue Episoden werden nur gemeldet, wenn sie zeitlich nicht in ferner
Vergangenheit liegen — sonst löste der erste Abgleich einer alten Serie hundert
Meldungen aus.

Der Zeitplan ist ein einfacher Timer statt einer Cron-Bibliothek: Flimmer
braucht genau einen täglichen Lauf. Wer mehr will, stösst /api/cron/sync von
aussen an.
2026-09-01 20:24:47 +02:00
Florian Wilde f49ac17922 feat: Benachrichtigungen über E-Mail, ntfy, Gotify und Webhook
Vier Wege, ein Nachrichtenformat. Der Webhook trägt sowohl content als auch
text im Rumpf — damit bedient derselbe Kanal Discord, Slack und Mattermost,
ohne dass jemand ein Format auswählen muss.

Auch ein Fehlversuch wird protokolliert: sonst versucht es jeder nächste Lauf
erneut und der Kanal wird zugespammt, sobald er wieder erreichbar ist.

Die E-Mail-Vorlage ist schlichtes HTML mit Inline-Stilen — Mailprogramme
können weder externe Stylesheets noch CSS-Variablen. Das Logo steckt als
Inline-SVG drin.
2026-09-01 20:24:45 +02:00
Florian Wilde 7ac598ea81 docs: Changelog, Release-Notes und DEVLOG für 0.5.0 2026-09-01 19:54:17 +02:00
Florian Wilde 075b42584a fix: Kalender zeigt auch abgeschlossene, weiterlaufende Serien
Wer bei einer laufenden Serie auf dem Stand ist, gilt als „abgeschlossen" —
und verlor damit die kommenden Folgen aus dem Kalender, obwohl man genau die
dort sehen will. Jetzt faellt nur Abgebrochenes heraus.

Ausserdem: Der SVG-Titel des Diagramms brach die Hydration, weil React
<title> als Dokument-Metadatum behandelt und aus dem SVG heraushebt.
2026-09-01 19:54:16 +02:00
Florian Wilde 8c872b240a feat: Seiten für Übersicht, Kalender und Statistik
Der Kalender ist als Tabelle ausgezeichnet — ein Kalender ist eine Tabelle,
und mit Screenreader wird daraus dann „Woche 3, Donnerstag, 24." statt einer
ununterscheidbaren Kachelwüste.
2026-09-01 19:54:16 +02:00
Florian Wilde 0e51df7bad feat: Diagramme als handgeschriebenes SVG
Ohne Chart-Bibliothek: die Diagramme sind schlicht, sollen sich an die
Design-Tokens halten und in beiden Farbschemata funktionieren.

Die Serienfarben sind abgedunkelte Signaturfarben. Sie bestehen in beiden
Modi die Pruefungen auf Helligkeitsband, Saettigung, Farbfehlsichtigkeit
(schlechtestes Paar 12,1 unter Deuteranopie) und Kontrast; die vollen
Signaturtoene waeren auf dunklem Grund zu hell.

Markenfarben der Dienste kommen nur zum Zug, wenn sie auf hellem und dunklem
Grund mindestens 3:1 erreichen — sonst faellt der Balken je nach Farbschema
in den Hintergrund.
2026-09-01 19:54:15 +02:00
Florian Wilde b53ee828ba feat: Auswertungen für Übersicht, Kalender und Statistik
Alles wird aus den Sichtungen berechnet — es gibt keine fortgeschriebenen
Zähler, die auseinanderlaufen könnten. Zeitreihen laufen als SQL, weil Prisma
keine Gruppierung nach Monat kennt.

Der Monatsschlüssel kommt bewusst als Text aus der Datenbank: date_trunc
liefert Mitternacht in der Sitzungszeitzone, JavaScript legt beim Vergleich
die lokale Zone an — zwei Stunden Versatz genügen, damit kein Schlüssel mehr
passt und das Diagramm leer bleibt.
2026-09-01 19:54:15 +02:00
Florian Wilde ed6011384a feat: Beispieluniversum im Seed und Notizen fuer 0.4.0 2026-09-01 19:39:35 +02:00
Florian Wilde 5cf4fc48a6 fix: server-only-Kette aus dem Browser-Bundle lösen
Die Posterkachel zog über lib/images.ts die serverseitige Cache-Logik in den
Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert wurde
— der Build brach ab, sobald eine solche Seite dazukam.

Die reine URL-Bildung liegt jetzt in lib/image-url.ts und darf überall
verwendet werden; Abruf und Dateicache bleiben hinter server-only.
2026-09-01 19:39:34 +02:00
Florian Wilde 5ee6946fd5 feat: Oberfläche für Universen
Umsortieren geht per Ziehen und über Pfeilknöpfe: reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig — die Knöpfe sind der
verlässliche Weg, das Ziehen der bequeme.

Die Erscheinungsreihenfolge lässt sich gar nicht erst anfassen, weil sie sich
aus den Daten ergibt.
2026-09-01 19:39:32 +02:00
Florian Wilde 90497ddc3b test: Universen absichern
12 Tests für Reihenfolgen, Fortschritt über Filme und Serien hinweg,
Freigaben mit Lese- und Schreibrecht sowie die Rechteprüfung gegenüber
Fremden.
2026-09-01 19:39:32 +02:00
Florian Wilde 190d7b3796 feat: Universen mit mehreren Reihenfolgen und Fortschritt
Zwei Dinge unterscheiden ein Universum von einer Liste: es hat mehrere
Reihenfolgen, und der Fortschritt ist der des jeweiligen Betrachters.

Die Erscheinungsreihenfolge wird aus den Erscheinungsdaten berechnet und nie
gespeichert — sie stimmt damit immer, auch wenn später Titel dazukommen.
Alles andere ist frei sortierbar.

Ein geteiltes Universum zeigt jeder Person ihren eigenen Stand; bei Serien
schrumpft die Restlaufzeit anteilig mit den gesehenen Episoden.
2026-09-01 19:39:31 +02:00
149 changed files with 17126 additions and 258 deletions
+1
View File
@@ -4,6 +4,7 @@ node_modules
.env .env
.env.local .env.local
data data
backups
docs/screenshots docs/screenshots
*.log *.log
test-results test-results
+13
View File
@@ -27,6 +27,9 @@ AUTH_SECRET=bitte-durch-zufallswert-ersetzen
# Ablage für Bild-Cache und sonstige Laufzeitdaten. # Ablage für Bild-Cache und sonstige Laufzeitdaten.
DATA_DIR=/app/data DATA_DIR=/app/data
# Port, auf dem Flimmer auf dem Host lauscht.
APP_PORT=3000
# Zeitzone für Kalender, Statistiken und geplante Läufe # Zeitzone für Kalender, Statistiken und geplante Läufe
TZ=Europe/Berlin TZ=Europe/Berlin
@@ -45,6 +48,9 @@ TMDB_ACCESS_TOKEN=
# Sprache der Metadaten und Region für die Streaming-Verfügbarkeit # Sprache der Metadaten und Region für die Streaming-Verfügbarkeit
TMDB_LANGUAGE=de-DE TMDB_LANGUAGE=de-DE
TMDB_REGION=DE TMDB_REGION=DE
# Nur nötig, wenn TMDB über einen eigenen Proxy erreicht werden soll.
# TMDB_BASE_URL=https://api.themoviedb.org/3
# TMDB_IMAGE_BASE_URL=https://image.tmdb.org/t/p
# --- Hintergrundläufe -------------------------------------------------------- # --- Hintergrundläufe --------------------------------------------------------
# Eingebauter Scheduler (prüft laufende Serien auf neue Episoden, räumt den # Eingebauter Scheduler (prüft laufende Serien auf neue Episoden, räumt den
@@ -65,6 +71,13 @@ SMTP_PASSWORD=
SMTP_SECURE=false SMTP_SECURE=false
SMTP_FROM=Flimmer <flimmer@example.com> 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) --------------------------------------------------- # --- OIDC / SSO (optional) ---------------------------------------------------
# Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert. # Anbindung an Authelia, Keycloak, Authentik … Leer lassen = deaktiviert.
# Redirect-URI beim Anbieter eintragen: <APP_URL>/api/auth/oidc/callback # Redirect-URI beim Anbieter eintragen: <APP_URL>/api/auth/oidc/callback
+63
View File
@@ -0,0 +1,63 @@
# Prüft jeden Push: Typen, Tests, Produktionsbuild.
#
# Die Tests brauchen eine echte PostgreSQL-Instanz — sie prüfen Abfragen und
# Fortschrittsberechnungen gegen die Datenbank, nicht gegen Attrappen. Einen
# TMDB-Schlüssel brauchen sie nicht: die TMDB-Antworten sind nachgebildet.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
pruefen:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: flimmer
POSTGRES_PASSWORD: flimmer-ci
POSTGRES_DB: flimmer
options: >-
--health-cmd "pg_isready -U flimmer -d flimmer"
--health-interval 5s
--health-timeout 5s
--health-retries 12
ports:
- 5432:5432
env:
DATABASE_URL: postgresql://flimmer:flimmer-ci@localhost:5432/flimmer?schema=public
AUTH_SECRET: ci-geheimnis-0123456789abcdef0123456789abcdef
APP_URL: http://localhost:3000
TZ: Europe/Berlin
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Abhängigkeiten installieren
run: npm ci --no-audit --no-fund
- name: Prisma-Client erzeugen
run: npx prisma generate
- name: Schema einspielen
run: npx prisma migrate deploy
- name: Typen prüfen
run: npm run typecheck
- name: Tests
run: npm test
- name: Produktionsbuild
run: npx next build
+56
View File
@@ -0,0 +1,56 @@
# Legt beim Pushen eines Versions-Tags automatisch das Release an.
#
# Die Notizen kommen aus releases/vX.Y.Z.md — derselben Datei, die auch von
# Hand für ein Release taugt. So gibt es genau eine Quelle für den Text, und
# der Ablauf funktioniert mit und ohne CI.
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
veroeffentlichen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Release-Notizen einsammeln
id: notes
run: |
VERSION="${GITHUB_REF_NAME#v}"
FILE="releases/${GITHUB_REF_NAME}.md"
if [ ! -f "$FILE" ]; then
echo "Es gibt keine $FILE — bitte anlegen, bevor der Tag gepusht wird." >&2
exit 1
fi
# Die Überschrift der Datei ist der Titel; alles darunter der Text.
{
echo "version=$VERSION"
echo "body<<FLIMMER_EOF"
tail -n +2 "$FILE"
echo "FLIMMER_EOF"
} >> "$GITHUB_OUTPUT"
- name: Release anlegen
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
BODY: ${{ steps.notes.outputs.body }}
VERSION: ${{ steps.notes.outputs.version }}
run: |
PAYLOAD=$(jq -n \
--arg tag "$GITHUB_REF_NAME" \
--arg name "$VERSION" \
--arg body "$BODY" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
curl -sSf -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
+2
View File
@@ -11,6 +11,7 @@ next-env.d.ts
# Produktion / Laufzeit # Produktion / Laufzeit
data/ data/
backups/
*.tsbuildinfo *.tsbuildinfo
# Umgebung — niemals committen! # Umgebung — niemals committen!
@@ -42,3 +43,4 @@ Thumbs.db
/blob-report/ /blob-report/
.playwright/ .playwright/
src/generated/ src/generated/
.env.composetest
+429 -1
View File
@@ -7,6 +7,429 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [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
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
wieder antwortet.
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
Weitere Eigenheiten:
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
unterbleibt dann, statt sie zu überfahren.
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
Healthcheck des Containers.
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
Compose-Datei als Vorgabewert.
## [0.7.0] — 2026-09-01
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
gestartet, und dabei kamen Fehler ans Licht, die nur der echte Containerstart
zeigt.
### Hinzugefügt
- **Eigene Sammlung**: Jeder Eintrag lässt sich als „hab ich" markieren, mit
Angabe wo — Jellyfin, Plex, Emby, NAS, Blu-ray, DVD oder frei getippt.
Bewusst getrennt vom Feld „wo schaue ich das": Streaming-Verfügbarkeit kommt
und geht, der gekaufte Film auf dem Heimserver bleibt. Gerade die Titel, die
nirgends mehr laufen, will man ja wiederfinden.
- Abzeichen auf der Posterkachel und im Kopf der Detailseite
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit
— was man hat, ist die verlässlichere Antwort auf „wo kann ich das schauen"
- Eigener Filter in der Bibliothek samt Zähler
- In Export (CSV und JSON), CSV-Import und REST-API (`?sammlung=1` beim
Lesen, `owned` und `ownedSource` beim Ändern)
### Behoben
- **Der Container konnte seine Migrationen nicht einspielen.** Das Laufzeitimage
bekam die Prisma-CLI, indem einzelne Ordner aus `node_modules` kopiert wurden
— dabei fehlten transitive Abhängigkeiten von `@prisma/config` (`effect` und
weitere). Die CLI wird jetzt in einer eigenen Build-Stufe vollständig
installiert und liegt im Laufzeitimage unter `/node_modules`, wo sie den
getracten Abhängigkeitsbaum der Anwendung nicht stört.
- **Der Build hing im Container am Schriftabruf.** `next/font/google` lädt
Outfit beim Bauen von Google herunter — das macht den Build von einer
Netzverbindung abhängig und blieb im Container hängen. Die variable Fassung
liegt jetzt als Datei im Repository; weder beim Bauen noch zur Laufzeit geht
eine Anfrage nach außen. Für eine selbst gehostete Anwendung ist das ohnehin
das Richtige.
- **npm blockierte Prismas Install-Skripte** (Voreinstellung seit npm 11),
wodurch im Image die plattformpassende Engine fehlen konnte. Sie sind jetzt
gezielt für `prisma` und `@prisma/engines` erlaubt statt pauschal.
### Hinzugefügt
- **Erscheinungsmeldung für Watchlist-Filme.** Der Schalter dafür stand schon im
Profil, verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der
letzten Tage statt eines Zustandswechsels — TMDB verschiebt Termine
gelegentlich, und ein einmal verpasstes Datum soll die Meldung nicht für
immer verhindern.
- **Gitea-Actions-Workflows**: `ci.yml` prüft bei jedem Push Typen, Tests und
Produktionsbuild gegen eine echte PostgreSQL-Instanz; `release.yml` legt beim
Pushen eines Versions-Tags das Release mit den Notizen aus
`releases/vX.Y.Z.md` an.
- Diagrammfarben und ihre Prüfwerte in `docs/DESIGN.md` festgehalten; das
öffentliche Profil in die Anleitung aufgenommen.
### Entfernt
- Die ungenutzte `images`-Konfiguration in `next.config.ts`. `next/image` kommt
nirgends zum Einsatz — damit entfällt auch das sicherheitsrelevant aussehende
`dangerouslyAllowSVG`, das ohnehin wirkungslos war.
## [0.6.0] — 2026-09-01
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
— und eine bebilderte Anleitung.
### Hinzugefügt
- **Benachrichtigungen** über vier Wege: E-Mail (SMTP), ntfy, Gotify und ein
generischer Webhook, der zugleich Discord, Slack und Mattermost bedient.
Jeder Nutzer richtet seine Kanäle selbst ein, kann sie einzeln stummschalten
und mit einer Testnachricht ausprobieren. Dieselbe Meldung geht nie zweimal
raus — auch ein Fehlversuch wird protokolliert, sonst spammt der nächste Lauf
den Kanal voll, sobald er wieder erreichbar ist. Die E-Mail-Vorlage trägt
Logo und Farbwelt von Flimmer.
- **Täglicher Abgleich**: frischt laufende Serien bei TMDB auf, meldet neue
Episoden und neu verfügbare Watchlist-Titel, räumt Papierkorb und abgelaufene
Sitzungen. Eingebauter Zeitplan über `ENABLE_SCHEDULER` und `SYNC_HOUR`, von
außen anstoßbar über `/api/cron/sync` mit `CRON_SECRET`, und in der
Verwaltung per Knopfdruck. Ein einzelner Titel kann den Lauf nicht kippen.
- **Import** aus Trakt.tv (JSON, egal ob Watchlist, History, Ratings oder
Collection) und aus CSV mit Komma oder Semikolon. Der Import überschreibt
nichts: was schon in der Bibliothek liegt, bleibt unangetastet.
- **Export** der eigenen Daten als CSV oder als JSON mit allen Sichtungen und
Universen.
- **REST-API** unter `/api/v1` mit persönlichen API-Schlüsseln: Bibliothek
lesen und pflegen, Episoden abhaken und zurücknehmen, suchen, Kalender,
Statistik, Universen, Export. Beschreibung als OpenAPI 3.1 unter
`/api/v1/openapi.json`, dazu `docs/API.md` mit Beispielen.
- **Einstellungsseiten** für Profil, Region, Passwort, Benachrichtigungen,
API-Schlüssel sowie Import und Export.
- **Verwaltungsbereich**: Instanzname, Registrierung öffnen und schließen,
öffentliche Profile erlauben, Konten sperren und Rechte vergeben, Abgleich
anstoßen und der Zustand der Instanz auf einen Blick. Die letzte
Administration lässt sich nicht entmachten.
- **Öffentliche Profilseite** unter `/u/<adresse>` — Zahlen und zuletzt
Gesehenes, aber keine Notizen, keine Watchlist, keine E-Mail-Adresse.
Abschaltbar durch den Nutzer *und* instanzweit durch den Betreiber.
- **PWA-Feinschliff**: eigener Service Worker (Poster aus dem Zwischenspeicher,
Seiten zuerst aus dem Netz, Offline-Rückfall) und eine Offline-Seite.
- **`TMDB_BASE_URL`** für Instanzen hinter einem eigenen Proxy — und für den
mitgelieferten TMDB-Ersatz `scripts/tmdb-stub.mjs`, mit dem sich die Suche
ohne Schlüssel aus den Beispieldaten ausprobieren lässt.
- **Bebilderte Anleitung** `docs/ANLEITUNG.md` mit 18 Screenshots aus der
laufenden Instanz, aufgenommen von `npm run screenshots`.
- **9 weitere Tests** für CSV- und Trakt-Import, Export, das Aufräumen des
Papierkorbs und den Schutz vor Doppelversand — zusammen 49.
## [0.5.0] — 2026-09-01
Übersicht, Kalender und Statistik.
### Hinzugefügt
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
verfügbar geworden sind.
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
zählen mit.
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
Standardton.
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
„Weiterschauen" und den Kalenderzeitraum.
### Behoben
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
(„2026-04") aus der Datenbank.
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
hängt jetzt als `aria-label` am `<svg>`.
## [0.4.0] — 2026-09-01
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
### Hinzugefügt
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
Wizarding World oder was auch immer zusammengehört.
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig.
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
vorkommen, lassen sich mit einem Klick übernehmen.
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
Serien hinweg, Freigaben und die Rechteprüfung.
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
Serien und einer abweichenden Story-Chronologie.
### Behoben
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
serverseitig.
## [0.3.0] — 2026-09-01 ## [0.3.0] — 2026-09-01
Das Kernstück: Bibliothek, Status und Episodenfortschritt. Das Kernstück: Bibliothek, Status und Episodenfortschritt.
@@ -132,7 +555,12 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
dass weitere Sprachen als zusätzliche Datei dazukommen können. dass weitere Sprachen als zusätzliche Datei dazukommen können.
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation. - **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.3.0...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.1...HEAD
[0.7.1]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.1
[0.7.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.0
[0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
[0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
[0.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0 [0.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0
[0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0 [0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0
[0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0 [0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0
+52 -2
View File
@@ -96,12 +96,62 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`), - **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt). Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`. - **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. - Next hängt selbst einen Block an diese Datei an — der bleibt drin.
## Fortschritt ## Fortschritt
- ✅ **Etappe 1** (v0.1.0, released) — Gerüst, Branding, Docker, Schema, Auth. 38 min. - ✅ **Etappe 1** (v0.1.0) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
- ⏳ **Etappe 2** — TMDB-Anbindung - ✅ **Etappe 2** (v0.2.0) — TMDB: Suche, Import, Episoden, Bild-Cache. 14 min.
- ✅ **Etappe 3** (v0.3.0) — Bibliothek, Status, Episodenfortschritt, Papierkorb. 24 min.
- ✅ **Etappe 4** (v0.4.0) — Universen mit Reihenfolgen, Freigaben. 14 min.
- ✅ **Etappe 5** (v0.5.0) — Übersicht, Kalender, Statistik. 15 min.
- ✅ **Etappe 6** (v0.6.0) — Benachrichtigungen, Abgleich, Import/Export, REST-API, PWA, Anleitung. 31 min.
**Alle sechs Etappen abgeschlossen. Gesamtzeit: 2 h 16 min.**
- ✅ **Nacharbeiten** (v0.7.0/0.7.1) — Docker verifiziert, „hab ich", `update.sh`. 51 min.
- ✅ **Issue-Runde** (v0.8.0) — Passkeys, Jellyfin-Anbindung, Standardreihenfolge,
Rückmeldung beim Tippen, Titelraster, Filmsichtung zurücknehmen. 77 min.
- ✅ **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
`TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 PORT=3011 npm run dev`
- Screenshots neu aufnehmen: `npm run screenshots` (nimmt auf und verkleinert)
- `npm test` läuft seriell gegen die Dev-Datenbank, braucht keinen TMDB-Schlüssel
- Deutsche Anführungszeichen (`„…"`) niemals in JS-**String-Literalen** —
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 --> <!-- BEGIN:nextjs-agent-rules -->
+171 -1
View File
@@ -49,6 +49,138 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
--- ---
## Etappe 4 — Universen mit Reihenfolgen und Fortschritt (v0.4.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 4.1 | 19:25 | 19:30 | 5 min | Domänenlogik: Universen, berechnete Erscheinungsreihenfolge, frei sortierbare Reihenfolgen, Fortschritt aus Sicht des Betrachters, Freigaben |
| 4.2 | 19:30 | 19:33 | 3 min | 12 Tests dazu; eine falsche Testannahme zur Reihenfolge korrigiert (nicht den Code) |
| 4.3 | 19:33 | 19:36 | 3 min | Server-Aktionen, sortierbare Liste mit Ziehen und Pfeilknöpfen, Reihenfolge-Umschalter, Aufnehmen, Teilen |
| 4.4 | 19:36 | 19:39 | 3 min | Seiten für Übersicht, Anlegen und Detailansicht; Build-Fehler durch `server-only` im Browser-Bundle gefunden und behoben; Umsortieren im Browser geprüft |
**Etappe 4 gesamt: 14 min** (2026-09-01, 19:25 – 19:39)
---
## Etappe 5 — Dashboard, Kalender, Statistiken (v0.5.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 5.1 | 19:39 | 19:43 | 4 min | Auswertungsschicht: Weiterschauen, kommende Episoden, neu verfügbar, Gesamtzahlen, Monatsverlauf, Verteilungen, Jahresrückblick |
| 5.2 | 19:43 | 19:46 | 3 min | Diagrammfarben gegen die Prüfungen auf Helligkeit, Sättigung, Farbfehlsichtigkeit und Kontrast gerechnet; SVG-Diagramme und Verteilungsbalken gebaut |
| 5.3 | 19:46 | 19:50 | 4 min | Seiten für Übersicht, Kalender und Statistik |
| 5.4 | 19:50 | 19:54 | 4 min | Drei Fehler gefunden und behoben: leeres Monatsdiagramm durch Zeitzonenversatz, Hydrationsbruch durch SVG-Titel, Kalender ohne abgeschlossene Serien; 7 Tests nachgezogen |
**Etappe 5 gesamt: 15 min** (2026-09-01, 19:39 – 19:54)
---
## Etappe 6 — Benachrichtigungen, Sync, Import/Export, REST-API, PWA (v0.6.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 6.1 | 19:54 | 19:58 | 4 min | Benachrichtigungen über vier Wege samt E-Mail-Vorlage; Hintergrundlauf mit Zeitplan und Cron-Endpunkt |
| 6.2 | 19:58 | 20:02 | 4 min | Import (Trakt, CSV) und Export (CSV, JSON) |
| 6.3 | 20:02 | 20:07 | 5 min | REST-API `/api/v1` mit API-Schlüsseln, OpenAPI-Beschreibung |
| 6.4 | 20:07 | 20:12 | 5 min | Einstellungsseiten, Verwaltung, öffentliches Profil; Service Worker und Offline-Seite |
| 6.5 | 20:12 | 20:15 | 3 min | 9 Tests für Import, Export, Papierkorb und Doppelversandschutz |
| 6.6 | 20:15 | 20:20 | 5 min | TMDB-Ersatz für Screenshots, 18 Aufnahmen, Verkleinerung auf WebP (13 MB → 0,6 MB) |
| 6.7 | 20:20 | 20:25 | 5 min | `docs/ANLEITUNG.md` und `docs/API.md`, README erweitert, API von Hand durchgetestet |
**Etappe 6 gesamt: 31 min** (2026-09-01, 19:54 – 20:25)
---
## Nach der Abnahme (v0.7.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 7.1 | 20:25 | 20:50 | 25 min | Docker-Image zum ersten Mal wirklich gebaut und gestartet; zwei Fehler behoben (fehlende Prisma-Abhängigkeiten im Laufzeitimage, Schriftabruf beim Bauen), Gitea-Workflows, Erscheinungsmeldung nachgezogen |
| 7.2 | 20:50 | 21:05 | 15 min | Compose-Stack von null verifiziert: Migration, erstes Konto wird Administrator, Verwaltung erreichbar |
| 7.3 | 21:05 | 21:16 | 11 min | Rückfrage aus dem Betrieb umgesetzt: „hab ich" für die eigene Sammlung (Jellyfin, Plex, NAS, Blu-ray) — Schema, Logik, Oberfläche, Filter, Import/Export, API, Tests, Anleitung |
**Nacharbeiten gesamt: 51 min** (2026-09-01, 20:25 – 21:16)
---
## 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 ## Gesamtübersicht
| Etappe | Version | Dauer | | Etappe | Version | Dauer |
@@ -56,5 +188,43 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min | | 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min |
| 2 — TMDB-Anbindung | 0.2.0 | 14 min | | 2 — TMDB-Anbindung | 0.2.0 | 14 min |
| 3 — Kern-Tracking | 0.3.0 | 24 min | | 3 — Kern-Tracking | 0.3.0 | 24 min |
| 4 — Universen | 0.4.0 | 14 min |
| 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 |
**Bisher gesamt: 76 min** **Gesamtentwicklungszeit: 5 h 01 min** (301 min, 2026-09-01 18:09 – 2026-09-02 11:13)
### Womit die Zeit verging
| Anteil | Bereich |
|---|---|
| ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) |
| ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) |
| ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) |
| ~25 min | Tests — 52 Stück, die unterwegs acht echte Fehler gefunden haben |
| ~15 min | Dokumentation, Screenshots, Changelog, Releases |
| ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette |
### Fehler, die die Tests gefunden haben
1. `importSeries` gab veraltete Staffel- und Episodenzähler zurück.
2. Testdateien räumten sich im parallelen Lauf gegenseitig die Fixtures weg.
3. Das Monatsdiagramm blieb wegen eines Zeitzonenversatzes leer.
4. Abgeschlossene, aber weiterlaufende Serien verschwanden aus dem Kalender.
5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel.
6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build.
7. Der SVG-Titel eines Diagramms brach die Hydration.
8. Eine Testerwartung zur Reihenfolge war falsch — nicht der Code.
### Fehler, die erst der echte Containerstart zeigte
1. Dem Laufzeitimage fehlten transitive Abhängigkeiten der Prisma-CLI, die
Migration lief nicht an. Die CLI wird jetzt in einer eigenen Build-Stufe
vollständig installiert.
2. npm blockiert seit Version 11 Install-Skripte; ohne Freigabe fehlte die
plattformpassende Prisma-Engine.
3. `next/font/google` lud die Schrift beim Bauen herunter und ließ den Build
im Container hängen. Die Schrift liegt jetzt im Repository.
+22 -3
View File
@@ -16,6 +16,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
# npm blockt Install-Skripte seit Version 11. Welche laufen dürfen, steht im
# Feld "allowScripts" der package.json — für Prisma, damit die zur Plattform
# passende Engine bereitgelegt wird.
RUN npm ci --no-audit --no-fund RUN npm ci --no-audit --no-fund
# --- Build ------------------------------------------------------------------- # --- Build -------------------------------------------------------------------
@@ -32,6 +35,18 @@ ENV NODE_ENV=production
RUN npx prisma generate && npx next build RUN npx prisma generate && npx next build
# --- Prisma-CLI --------------------------------------------------------------
# Eigene Stufe, damit die CLI samt ihrer transitiven Abhängigkeiten vollständig
# ins Laufzeitimage kommt. Einzelne Pakete aus node_modules herauszupicken ging
# schief: @prisma/config braucht weitere Module, die dabei fehlten.
FROM base AS prisma-cli
WORKDIR /cli
# Die package.json liegt als Datei bei: nur mit dem Feld "allowScripts" lässt
# npm die Install-Skripte von Prisma zu — sonst fehlt im Image die zur
# Plattform passende Engine.
COPY docker/prisma-cli-package.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund prisma@7.10.0
# --- Laufzeit ---------------------------------------------------------------- # --- Laufzeit ----------------------------------------------------------------
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
@@ -48,12 +63,16 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=flimmer:nodejs /app/.next/standalone ./ COPY --from=builder --chown=flimmer:nodejs /app/.next/standalone ./
COPY --from=builder --chown=flimmer:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=flimmer:nodejs /app/.next/static ./.next/static
# Prisma-CLI und Schema bleiben im Image, damit der Container beim Start seine # Schema und Migrationen bleiben im Image, damit der Container beim Start seine
# eigenen Migrationen ausspielen kann (`prisma migrate deploy`). # eigenen Migrationen ausspielen kann (`prisma migrate deploy`).
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.mjs ./prisma.config.mjs COPY --from=builder /app/prisma.config.mjs ./prisma.config.mjs
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma # Die CLI liegt bewusst unter /node_modules statt in /app/node_modules: dort
# stört sie den getracten Abhängigkeitsbaum der Anwendung nicht, und Node
# findet sie beim Auflösen von "prisma/config" trotzdem — die Suche wandert
# von /app aus nach oben.
COPY --from=prisma-cli /cli/node_modules /node_modules
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh \ RUN chmod +x /usr/local/bin/entrypoint.sh \
+103 -11
View File
@@ -15,6 +15,14 @@ der Flimmerkiste und dem warmen Flimmern, das ein Bildschirm im dunklen Zimmer
verbreitet. Metadaten kommen von [TMDB](https://www.themoviedb.org/), die Daten verbreitet. Metadaten kommen von [TMDB](https://www.themoviedb.org/), die Daten
bleiben auf deinem Server. bleiben auf deinem Server.
<p align="center">
<img src="docs/screenshots/02-uebersicht.webp" alt="Die Übersicht von Flimmer" width="820">
</p>
> **Bebilderte Anleitung:** [docs/ANLEITUNG.md](docs/ANLEITUNG.md) —
> führt einmal durch die ganze Anwendung.
> **Schnittstelle:** [docs/API.md](docs/API.md).
## Was Flimmer kann ## Was Flimmer kann
- **Tracking** — Serien und Filme mit Status (Schaue gerade, Pausiert, - **Tracking** — Serien und Filme mit Status (Schaue gerade, Pausiert,
@@ -25,12 +33,20 @@ bleiben auf deinem Server.
Story-Chronologie, Einsteiger-Route) und Gesamtfortschritt. Story-Chronologie, Einsteiger-Route) und Gesamtfortschritt.
- **Metadaten von TMDB** — Suche, Poster, Staffeln und Episoden, offizielle - **Metadaten von TMDB** — Suche, Poster, Staffeln und Episoden, offizielle
Collections, Streaming-Verfügbarkeit in deiner Region. Collections, Streaming-Verfügbarkeit in deiner Region.
- **Eigene Sammlung** — markiere, was du schon hast, und wo: Jellyfin, Plex,
NAS, Blu-ray. Gerade die Titel, die nirgends mehr streamen, findest du so
wieder.
- **Benachrichtigungen** — bei neuen Episoden und Releases, per E-Mail, ntfy, - **Benachrichtigungen** — bei neuen Episoden und Releases, per E-Mail, ntfy,
Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar. Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar.
- **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen - **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen
sich teilen. Der erste registrierte Zugang wird Administrator. sich teilen. Der erste registrierte Zugang wird Administrator.
- **Übersicht, Kalender und Statistik** — was als Nächstes dran ist, wann welche
Episode läuft, und wie viel Zeit wohin geflossen ist.
- **Import und Export** — aus Trakt.tv oder CSV hinein, als CSV oder JSON hinaus.
- **REST-API und PWA** — dokumentierte Schnittstelle mit eigenen API-Schlüsseln, - **REST-API und PWA** — dokumentierte Schnittstelle mit eigenen API-Schlüsseln,
installierbar auf dem Startbildschirm. installierbar auf dem Startbildschirm, mit Offline-Rückfall.
- **Papierkorb statt Löschen** — 30 Tage Bedenkzeit, und jede Sichtung lässt sich
mit einem Klick zurücknehmen.
## Schnellstart ## Schnellstart
@@ -98,14 +114,31 @@ flimmer.example.com {
```bash ```bash
cd flimmer cd flimmer
git pull ./update.sh
docker compose build
docker compose up -d
``` ```
Datenbankmigrationen laufen automatisch beim Start des Containers — ein eigener Das Skript sichert zuerst die Datenbank, holt die Änderungen, baut das Image neu
Handgriff ist nicht nötig. **Vor** einem Update ein Backup zu ziehen ist und wartet, bis die Instanz wieder antwortet. Schlägt etwas fehl, nennt es den
trotzdem eine gute Angewohnheit. konkreten Weg zurück — mit dem Befehl und der Sicherungsdatei, nicht bloß dem
Rat, ein Backup zu haben.
```
./update.sh --ohne-backup ohne Datenbanksicherung
./update.sh --ohne-pull nur neu bauen und starten
./update.sh --behalte 20 wie viele Sicherungen aufgehoben werden (Standard: 10)
./update.sh --hilfe
```
Die Sicherungen landen unter `backups/` und sind gzip-komprimierte `pg_dump`s.
Von Hand geht es genauso:
```bash
git pull && docker compose build && docker compose up -d
```
Datenbankmigrationen laufen in beiden Fällen automatisch beim Start des
Containers — ein eigener Handgriff ist nicht nötig.
## Backup ## Backup
@@ -145,6 +178,31 @@ mit Erklärungen steht in [`.env.example`](.env.example). Die wichtigsten:
| `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen | | `ENABLE_SCHEDULER` | Eingebauter Hintergrundlauf für Sync und Benachrichtigungen |
| `SMTP_*` | E-Mail-Versand | | `SMTP_*` | E-Mail-Versand |
| `OIDC_*` | Optionale SSO-Anbindung (Authelia, Keycloak, Authentik …) | | `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 ## Entwicklung
@@ -165,22 +223,56 @@ npm run dev
| `npm run db:migrate` | Migration erzeugen und anwenden | | `npm run db:migrate` | Migration erzeugen und anwenden |
| `npm run db:studio` | Prisma Studio | | `npm run db:studio` | Prisma Studio |
| `npm run db:seed` | Beispieldaten | | `npm run db:seed` | Beispieldaten |
| `npm test` | Testlauf gegen die Entwicklungsdatenbank (braucht keinen TMDB-Schlüssel) |
| `npm run icons` | Favicon und PWA-Icons aus dem SVG-Logo neu erzeugen | | `npm run icons` | Favicon und PWA-Icons aus dem SVG-Logo neu erzeugen |
| `npm run screenshots` | Screenshots der Anleitung neu aufnehmen |
Ohne TMDB-Schlüssel lässt sich die Suche mit einem lokalen Ersatz ausprobieren,
der aus den Beispieldaten antwortet:
```bash
node --import tsx scripts/tmdb-stub.mjs 3099
TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
```
Aufbau des Projekts: Aufbau des Projekts:
``` ```
src/ src/
app/ Routen (App Router), API-Endpunkte app/ Routen (App Router), API-Endpunkte, Server-Aktionen
components/ Wiederverwendbare Bausteine components/ Wiederverwendbare Bausteine
lib/ Serverseitige Logik: DB, Auth, TMDB, Benachrichtigungen lib/ Serverseitige Logik: DB, Auth, TMDB, Tracking, Statistik,
Universen, Benachrichtigungen, Abgleich, Import/Export
i18n/ Oberflächentexte i18n/ Oberflächentexte
prisma/ Schema, Migrationen, Seed prisma/ Schema, Migrationen, Seed samt Beispielkatalog
tests/ Testlauf mit nachgebildeten TMDB-Antworten
public/brand/ Logo in allen Varianten public/brand/ Logo in allen Varianten
docker/ Entrypoint des Containers docker/ Entrypoint des Containers
docs/ Anleitung, Design-Dokumentation docs/ Anleitung mit Screenshots, API, Design-Dokumentation
``` ```
### Automatisierung
Unter `.gitea/workflows/` liegen zwei Abläufe für Gitea Actions: `ci.yml`
prüft bei jedem Push Typen, Tests und Produktionsbuild gegen eine echte
PostgreSQL-Instanz, `release.yml` legt beim Pushen eines Versions-Tags das
Release an — mit den Notizen aus `releases/vX.Y.Z.md`.
## Hintergrundlauf
Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue
Episoden, prüft die Verfügbarkeit von Watchlist-Titeln und leert den
Papierkorb. Gesteuert wird das über `ENABLE_SCHEDULER` und `SYNC_HOUR`.
Wer lieber einen eigenen Cron benutzt, setzt `ENABLE_SCHEDULER=false`, vergibt
ein `CRON_SECRET` und ruft auf:
```bash
curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://flimmer.example.com/api/cron/sync
```
In der Verwaltung lässt sich der Lauf jederzeit von Hand anstoßen.
## Technik ## Technik
Next.js (App Router) mit TypeScript, PostgreSQL über Prisma, Tailwind CSS. Next.js (App Router) mit TypeScript, PostgreSQL über Prisma, Tailwind CSS.
+6
View File
@@ -1,5 +1,11 @@
# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`. # Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`.
# docker compose -f docker-compose.dev.yml up -d # 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: services:
db: db:
image: postgres:17-alpine image: postgres:17-alpine
+1 -1
View File
@@ -40,7 +40,7 @@ attempt();
' '
echo "Flimmer: spiele Migrationen ein ..." echo "Flimmer: spiele Migrationen ein ..."
node node_modules/prisma/build/index.js migrate deploy node /node_modules/prisma/build/index.js migrate deploy
echo "Flimmer: starte Anwendung." echo "Flimmer: starte Anwendung."
exec "$@" exec "$@"
+9
View File
@@ -0,0 +1,9 @@
{
"name": "flimmer-prisma-cli",
"private": true,
"description": "Nur zum Installieren der Prisma-CLI ins Laufzeitimage. npm blockt Install-Skripte seit Version 11; ohne die Freigabe hier fehlt im Container die plattformpassende Engine. Bei einem Prisma-Update auch die Version in package.json anpassen.",
"allowScripts": {
"prisma@7.10.0": true,
"@prisma/engines@7.10.0": true
}
}
+561
View File
@@ -0,0 +1,561 @@
<p align="center">
<img src="../public/brand/flimmer-logo.svg" alt="Flimmer" width="320">
</p>
# Flimmer — Anleitung
Diese Anleitung führt einmal durch die ganze Anwendung: vom ersten Konto über
das tägliche Abhaken bis zu Universen, Statistik und Schnittstelle.
Die Bilder stammen aus einer Instanz mit Beispieldaten (`npm run db:seed`).
Die Poster darin sind erfunden — mit hinterlegtem TMDB-Schlüssel stehen dort
die echten.
## Inhalt
1. [Erster Start](#erster-start)
2. [Die Übersicht](#die-übersicht)
3. [Titel finden und aufnehmen](#titel-finden-und-aufnehmen)
4. [Eine Serie verfolgen](#eine-serie-verfolgen)
5. [Hab ich — die eigene Sammlung](#hab-ich--die-eigene-sammlung)
6. [Die Bibliothek](#die-bibliothek)
7. [Universen](#universen)
8. [Kalender](#kalender)
9. [Statistik](#statistik)
10. [Einstellungen](#einstellungen)
11. [Benachrichtigungen](#benachrichtigungen)
12. [Import und Export](#import-und-export)
13. [API-Schlüssel](#api-schlüssel)
14. [Papierkorb](#papierkorb)
15. [Verwaltung](#verwaltung)
16. [Öffentliches Profil](#öffentliches-profil)
17. [Auf dem Handy](#auf-dem-handy)
18. [Helles Farbschema](#helles-farbschema)
---
## Erster Start
![Anmeldung](screenshots/01-anmeldung.webp)
Nach der Installation ruf die Adresse deiner Instanz auf. Solange es noch kein
einziges Konto gibt, ist die Registrierung offen — **der erste Zugang wird
automatisch Administrator**. Lege ihn also gleich für dich an.
Danach kannst du die Registrierung in der [Verwaltung](#verwaltung) schließen,
damit sich niemand sonst anmeldet.
> Ist SSO eingerichtet (`OIDC_*` in der `.env`), erscheint hier zusätzlich ein
> Knopf für die Anmeldung über den eigenen Anbieter.
---
## Die Übersicht
![Übersicht](screenshots/02-uebersicht.webp)
Die Startseite beantwortet drei Fragen:
- **Weiterschauen** — welche Serien laufen gerade, und welche Episode ist als
Nächstes dran. Der Knopf **+1 Episode** hakt genau diese Folge ab; du musst
die Serie dafür nicht öffnen.
- **Demnächst** — welche Episoden deiner Serien in den nächsten vier Wochen
ausgestrahlt werden.
- **Neu verfügbar** — welche Titel von deiner Watchlist zuletzt auf einem
Streamingdienst in deiner Region aufgetaucht sind.
Ist die Bibliothek noch leer, steht hier stattdessen der Weg zur Suche.
---
## Titel finden und aufnehmen
![Suche](screenshots/03-suche.webp)
Über **Suchen** durchsuchst du TMDB. Die Treffer lassen sich auf *Alles*,
*Serien* oder *Filme* eingrenzen; die Suchanfrage steht in der Adresszeile und
ist damit teilbar.
Ein Klick auf einen Treffer holt die vollständigen Daten — bei Serien alle
Staffeln und Episoden — und öffnet die Detailseite. Von dort nimmst du den
Titel auf, wahlweise als **Ich schaue das gerade** oder **Auf die Watchlist**.
> **Nichts dabei?** Unter *Titel selbst anlegen* trägst du etwas ein, das TMDB
> nicht kennt — Heimvideos, Vorträge, regionale Produktionen. Solche Titel
> werden vom automatischen Abgleich nie angefasst.
---
## Eine Serie verfolgen
![Serie](screenshots/04-serie.webp)
Die Detailseite ist der Ort, an dem du eine Serie führst:
- **Fortschrittsbalken** mit gesehenen Episoden und verbleibender Laufzeit,
berechnet aus der echten Episodenanzahl.
- **Als Nächstes dran** nennt Staffel, Episode, Titel und Ausstrahlungsdatum.
Der Knopf **+1 Episode** hakt sie ab.
- Der **Status** (Schaue gerade, Watchlist, Pausiert, Abgeschlossen,
Abgebrochen) ergibt sich beim Abhaken von selbst: die erste Episode startet
die Serie, die letzte schließt sie ab. Pausiert und Abgebrochen bleiben
stehen — wer bewusst pausiert hat, will das nicht durch ein Häkchen
aufgehoben bekommen.
- In der **Staffelliste** hakst du einzelne Episoden ab, ganze Staffeln auf
einmal, oder über *Bis hierhin gesehen* alles Vorherige. Beim Abhaken glimmt
die Zeile kurz warm auf.
- Rechts stehen **Bewertung** (1 bis 10), **wo du das schaust** (mit Vorschlag
aus der Streaming-Verfügbarkeit), **Tags** und ein **Notizfeld**.
Jede Sichtung meldet sich unten kurz zurück und lässt sich mit **Rückgängig**
zurücknehmen.
Ist eine Serie durch, erscheint **Noch einmal schauen** — der Fortschritt
beginnt von vorn, die alten Sichtungen bleiben für die Statistik erhalten. Die
Karte zeigt dann „2. Durchlauf".
---
## Hab ich — die eigene Sammlung
![Sammlung](screenshots/20-sammlung.webp)
„Wo läuft das?" beantwortet nur die halbe Frage. Manche Filme laufen nirgends
mehr — die hat man gekauft und liegen bei sich zu Hause, in Jellyfin, Plex,
Emby, auf dem NAS oder als Scheibe im Regal.
Dafür gibt es das Häkchen **Hab ich** auf der Detailseite, rechts über dem Feld
„wo schaue ich das". Daneben trägst du ein, *wo* der Titel liegt; das Feld
schlägt die gängigen Möglichkeiten vor, nimmt aber auch alles andere an.
Was das bewirkt:
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit —
was du hast, ist die verlässlichere Antwort.
- Poster in der Bibliothek bekommen ein Abzeichen, und statt des
Streamingdienstes steht dort deine Quelle.
- In der Filterleiste gibt es einen eigenen Knopf **Hab ich** samt Zähler.
- Export und Import führen die Angabe mit (Spalten `HabIch` und `Sammlung`),
ebenso die REST-API.
Bewusst getrennt vom Feld „wo schaue ich das": Das eine sagt, wo ein Titel
gerade läuft — das ändert sich ständig. Das andere sagt, was dir gehört, und
das bleibt.
---
## Die Bibliothek
![Bibliothek](screenshots/05-bibliothek.webp)
Alles, was du verfolgst, als Posterraster. Jede Kachel zeigt Status,
Bewertung und — bei Serien — den Fortschritt.
Filtern kannst du nach:
| Filter | Wirkung |
|---|---|
| Volltextsuche | Titel und Originaltitel |
| Status | Schaue gerade, Watchlist, Pausiert, Abgeschlossen, Abgebrochen |
| Typ | nur Serien oder nur Filme |
| Genre | alle Genres, die in deiner Bibliothek vorkommen |
| Dienst | Streamingdienste deiner Titel |
| Tag | deine eigenen Schlagworte |
Sortieren geht nach zuletzt aktualisiert, zuletzt hinzugefügt, Titel, Bewertung
oder Erscheinungsjahr. Die gesamte Auswahl steht in der Adresszeile — ein
Lesezeichen auf „alle pausierten Krimis" ist damit möglich.
---
## Universen
![Universen](screenshots/06-universen.webp)
Ein Universum bündelt Titel, die zusammengehören — MCU, Star Wars, Wizarding
World — und zwar **Filme und Serien gemeinsam**.
![Universum](screenshots/07-universum.webp)
Auf der Detailseite steht oben der Gesamtfortschritt: wie viel abgeschlossen
ist, wie viel Zeit schon drinsteckt und wie lange es bis zum Ende noch dauert.
Der erste noch offene Eintrag ist als **Als Nächstes dran** hervorgehoben.
**Mehrere Reihenfolgen** sind der Kern der Sache:
- Die **Erscheinungsreihenfolge** ist immer da und wird aus den
Erscheinungsdaten berechnet — sie stimmt damit auch, wenn später etwas
dazukommt.
- Über **+ Reihenfolge** legst du weitere an, etwa *Chronologisch* oder
*Für Einsteiger*. Sortiert wird per Ziehen oder über die Pfeile neben jedem
Eintrag; gespeichert wird sofort.
Titel nimmst du rechts aus deiner Bibliothek auf. Gehört ein Film zu einer
TMDB-Reihe (etwa „Spider-Man Collection"), lässt sich die ganze Reihe mit einem
Klick übernehmen.
**Teilen** geht mit anderen Konten derselben Instanz, wahlweise nur zum Ansehen
oder zum gemeinsamen Pflegen. Der Fortschritt bleibt dabei persönlich: jede
Person sieht ihren eigenen Stand.
---
## Kalender
![Kalender](screenshots/09-kalender.webp)
Das Monatsraster zeigt, wann Episoden deiner Serien laufen. Tage mit Terminen
sind hinterlegt, der heutige Tag trägt den Signaturverlauf. Unter dem Raster
stehen dieselben Termine noch einmal als Liste mit vollem Datum.
Mit den Pfeilen blätterst du durch die Monate, **Heute** springt zurück.
Abgebrochene Serien tauchen hier nicht auf. Abgeschlossene schon — wer bei
einer laufenden Serie auf dem Stand ist, will die nächste Folge erst recht
sehen.
---
## Statistik
![Statistik](screenshots/10-statistik.webp)
Oben die **Gesamtschauzeit** in Stunden und umgerechnet in Tage am Stück, daneben
Episoden, Filme und abgeschlossene Titel.
- **Die letzten zwölf Monate** — abgehakte Episoden und Filme je Monat.
Beim Überfahren einer Säule erscheinen die genauen Zahlen samt Schauzeit.
- **Nach Streamingdienst** — wo du tatsächlich geschaut hast. Grundlage ist das
Feld *wo schaue ich das*, nicht die bloße Verfügbarkeit.
- **Nach Genre** — Titel mit mehreren Genres zählen in jedem mit.
- **Jahr in Zahlen** — Schauzeit, Episoden, Filme, neu Angefangenes,
Abgeschlossenes, der stärkste Monat und der Titel mit der meisten Zeit.
Über die Jahreszahlen wechselst du das Jahr.
- Darunter **wo die meiste Zeit hinging** und **deine besten Bewertungen**.
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)
Hier stellst du Anzeigename und **Region** ein — Letztere bestimmt, welche
Streamingdienste angezeigt werden (Standard: DE).
Darunter legst du fest, **wovon Flimmer dir berichten soll**: neue Episoden,
neue Releases, neu verfügbare Watchlist-Titel.
Wenn der Betreiber öffentliche Profile erlaubt, kannst du deins freischalten
und eine Adresse wählen. Die Profilseite zeigt Zahlen und zuletzt Gesehenes —
**keine** Notizen, keine Watchlist, keine E-Mail-Adresse.
Das Passwort änderst du im Abschnitt darunter; danach wirst du auf allen
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)
*Wovon* berichtet wird, steht im Profil — *wohin*, hier. Vier Wege stehen zur
Wahl:
| Kanal | Nötige Angaben |
|---|---|
| **E-Mail** | nichts (oder eine abweichende Adresse); der SMTP-Zugang kommt vom Betreiber |
| **ntfy** | Server (Standard `https://ntfy.sh`) und Topic, optional ein Token |
| **Gotify** | Serveradresse und Anwendungs-Token |
| **Webhook** | die Zieladresse — funktioniert mit Discord, Slack und Mattermost |
Jeder Kanal lässt sich einzeln stummschalten und mit **Testen** ausprobieren.
Unten steht, was zuletzt verschickt wurde — auch fehlgeschlagene Versuche mit
Grund.
Dieselbe Meldung kommt nie zweimal: Flimmer merkt sich, was schon draußen ist.
---
## Import und Export
![Import und Export](screenshots/14-daten.webp)
**Import** aus zwei Quellen:
- **CSV**, wie es Tabellenprogramme und viele Tracker ausgeben. Die Kopfzeile
braucht mindestens eine Titelspalte (`Titel`, `title`, `name` oder `show`);
erkannt werden außerdem Typ, Status, Bewertung, TMDB-ID, Jahr und Notiz.
Komma und Semikolon als Trennzeichen funktionieren beide.
- **Trakt.tv** — ein JSON-Export aus Watchlist, History, Ratings oder
Collection. Flimmer sucht sich die Einträge selbst heraus.
Der Import **überschreibt nichts**. Titel, die schon in deiner Bibliothek
liegen, bleiben unangetastet; es kommt nur dazu, was fehlt.
**Export** als JSON (mit allen Sichtungen und Universen) oder als CSV für
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)
Für Skripte und eigene Anwendungen. Ein Schlüssel ist **einmalig** sichtbar —
danach liegt in der Datenbank nur ein Hash. Er hat dieselben Rechte wie dein
Konto; zieh ihn zurück, sobald du ihn nicht mehr brauchst.
```bash
curl -H "Authorization: Bearer flm_…" \
https://flimmer.example.com/api/v1/bibliothek?status=WATCHING
```
Alle Endpunkte stehen in [`API.md`](API.md); die maschinenlesbare Fassung
liegt unter `/api/v1/openapi.json`.
---
## Papierkorb
![Papierkorb](screenshots/16-papierkorb.webp)
Entfernte Titel landen für **30 Tage** im Papierkorb und behalten dabei ihren
Fortschritt. Von dort lassen sie sich wiederherstellen oder sofort endgültig
löschen. Nimmst du denselben Titel erneut auf, lebt der alte Eintrag samt
Sichtungen wieder auf.
Nach Ablauf der Frist räumt der tägliche Hintergrundlauf auf.
---
## Verwaltung
![Verwaltung](screenshots/15-verwaltung.webp)
Nur für Administratoren. Hier lässt sich
- der **Name der Instanz** ändern,
- die **Registrierung** öffnen oder schließen,
- **öffentliche Profile** instanzweit erlauben oder abschalten,
- der **Abgleich** von Hand anstoßen,
- und der Zustand der Instanz ablesen: Anzahl Konten, Umfang des Katalogs und
ob TMDB, SMTP, Zeitplan und OIDC eingerichtet sind.
In der Kontenliste vergibst du Verwaltungsrechte und sperrst Konten. Ein
gesperrtes Konto wird sofort von allen Geräten abgemeldet. Die letzte
Administration lässt sich nicht entmachten, und sich selbst kann niemand
sperren.
---
## Öffentliches Profil
![Öffentliches Profil](screenshots/19-profil.webp)
Wer mag, kann eine schlichte Seite unter `/u/<adresse>` freischalten — mit
Schauzeit, Zahlen, zuletzt Gesehenem und den Listen „Die meiste Zeit" und
„Bestbewertet".
Was dort **nicht** steht: Notizen, Watchlist, E-Mail-Adresse, Tags. Die Seite
braucht zwei Freigaben — deine in den Einstellungen und die des Betreibers in
der Verwaltung. Fehlt eine davon, ist die Adresse schlicht nicht erreichbar.
---
## Auf dem Handy
![Auf dem Handy](screenshots/17-mobil-uebersicht.webp)
Die Oberfläche ist für schmale Geräte ausgelegt: die Navigation rutscht in eine
eigene, wischbare Zeile, Karten stapeln sich untereinander, und der Knopf
**+1 Episode** bleibt groß genug für den Daumen.
Flimmer ist als **PWA** installierbar — im Browsermenü *Zum Startbildschirm
hinzufügen*. Danach startet die App im eigenen Fenster ohne Adresszeile.
Bereits geladene Seiten und Poster bleiben auch ohne Verbindung sichtbar;
statt einer Fehlerseite erscheint dann ein Hinweis.
---
## Helles Farbschema
![Helles Farbschema](screenshots/08-hell.webp)
Über den Knopf im Kopfbereich wechselst du zwischen **Dunkel**, **Hell** und
**System**. Die Wahl bleibt auf dem Gerät gespeichert und greift, bevor die
Seite gezeichnet wird — es blitzt nichts auf.
---
## Und wenn etwas fehlt?
- **Die Suche findet nichts** → Es fehlt der TMDB-Schlüssel. Trag
`TMDB_API_KEY` in die `.env` ein und starte die Instanz neu.
- **Keine Streamingdienste** → Prüfe die Region in den Einstellungen. TMDB hat
nicht für jedes Land Daten zu jedem Titel.
- **Keine Benachrichtigungen** → Erst im Profil auswählen, *wovon* berichtet
werden soll, dann einen Kanal anlegen und mit *Testen* prüfen.
- **Neue Episoden fehlen** → Der Abgleich läuft einmal täglich. In der
Verwaltung lässt er sich sofort anstoßen.
Alles Weitere zur Installation, zum Aktualisieren und zum Backup steht in der
[README](../README.md).
+227
View File
@@ -0,0 +1,227 @@
# Flimmer — REST-API
Alles, was die Oberfläche kann, geht auch über die Schnittstelle: Bibliothek
lesen und pflegen, Episoden abhaken, suchen, Kalender und Statistik abrufen,
Universen verwalten, Daten exportieren.
Die maschinenlesbare Beschreibung liegt unter
`GET /api/v1/openapi.json` (OpenAPI 3.1) — damit lassen sich Clients erzeugen
oder die Endpunkte in Insomnia, Bruno oder Postman importieren.
## Anmeldung
Jede Anfrage trägt einen persönlichen API-Schlüssel im `Authorization`-Kopf:
```bash
curl -H "Authorization: Bearer flm_a1b2c3d4.xxxxxxxxxxxx" \
https://flimmer.example.com/api/v1/me
```
Schlüssel legst du unter **Einstellungen → API-Schlüssel** an. Der Klartext ist
**einmalig** sichtbar; danach liegt in der Datenbank nur ein Hash. Ein Schlüssel
hat dieselben Rechte wie dein Konto — behandle ihn wie ein Passwort und zieh ihn
zurück, sobald du ihn nicht mehr brauchst.
Aus dem Browser heraus funktioniert stattdessen das Sitzungscookie; dieselben
Endpunkte bedienen also Skripte und das eigene Frontend.
## Antwortformat
Alle Antworten sind JSON. Zeitangaben in ISO 8601, Laufzeiten in Minuten.
Fehler tragen immer ein Feld `error`:
```json
{ "error": "Diesen Eintrag gibt es nicht." }
```
| Status | Bedeutung |
|---|---|
| 200 | In Ordnung |
| 401 | Kein oder ungültiger Schlüssel |
| 404 | Nicht vorhanden (oder nicht deins) |
| 422 | Anfrage unvollständig — `details` nennt die Felder |
| 502 / 503 | TMDB nicht erreichbar bzw. kein Schlüssel hinterlegt |
## Endpunkte
### Konto
```http
GET /api/v1/me
```
### Bibliothek
```http
GET /api/v1/bibliothek?status=WATCHING&typ=SERIES&genre=Drama&q=nord&limit=50&offset=0
POST /api/v1/bibliothek
```
Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `sammlung`, `limit`,
`offset`. Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
`sammlung=1` liefert nur Titel, die in der eigenen Sammlung liegen.
Aufnehmen — entweder mit interner Kennung oder direkt mit der TMDB-Kennung:
```bash
curl -X POST -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"tmdbId": 1396, "type": "SERIES", "status": "WATCHING"}' \
https://flimmer.example.com/api/v1/bibliothek
```
```http
GET /api/v1/bibliothek/{entryId}
PATCH /api/v1/bibliothek/{entryId}
DELETE /api/v1/bibliothek/{entryId}
```
`GET` liefert den Eintrag samt `progress` und `nextEpisode`.
`PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite`
sowie `owned` (liegt der Titel in der eigenen Sammlung?) und `ownedSource`
(wo genau — Jellyfin, Plex, Blu-ray …).
`DELETE` legt den Eintrag in den Papierkorb — dort bleibt er 30 Tage.
### Sichtungen
```http
GET /api/v1/bibliothek/{entryId}/sichtungen
POST /api/v1/bibliothek/{entryId}/sichtungen
DELETE /api/v1/bibliothek/{entryId}/sichtungen
```
`POST` kennt fünf Formen:
| Rumpf | Wirkung |
|---|---|
| `{"action":"next"}` | Die nächste offene Episode abhaken |
| `{"action":"movie"}` | Einen Film als gesehen markieren |
| `{"action":"episodes","episodeIds":["…"]}` | Eine Auswahl abhaken |
| `{"action":"season","seasonId":"…"}` | Eine ganze Staffel |
| `{"action":"upTo","episodeId":"…"}` | Alles bis einschließlich dieser Episode |
Die Antwort trägt `watchIds` — damit lässt sich genau diese Aktion zurücknehmen:
```bash
# Nächste Episode abhaken
IDS=$(curl -s -X POST -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"action":"next"}' \
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen | jq -c .watchIds)
# … und wieder zurücknehmen
curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d "{\"watchIds\": $IDS}" \
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen
```
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
GET /api/v1/suche?q=nordlicht&typ=serie
```
Durchsucht TMDB. Die Treffer tragen `tmdbId` und `type` — direkt verwendbar für
`POST /api/v1/bibliothek`. Ohne hinterlegten TMDB-Schlüssel antwortet der
Endpunkt mit 503.
### Kalender
```http
GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30
```
Kommende Episoden der getrackten Serien. Ohne Zeitraum: die nächsten 30 Tage.
### Statistik
```http
GET /api/v1/statistik?jahr=2026
```
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}
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
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
```http
GET /api/v1/export?format=json
GET /api/v1/export?format=csv
```
JSON enthält zusätzlich alle Sichtungen und Universen.
## Beispiel: was ich habe, aber noch nicht gesehen habe
```bash
curl -s -H "Authorization: Bearer $KEY" \
"$HOST/api/v1/bibliothek?sammlung=1&status=WATCHLIST" \
| jq -r '.items[] | "\(.title) — liegt bei: \(.ownedSource // "ohne Angabe")"'
```
## Beispiel: Serien mit offener Episode auflisten
```bash
#!/usr/bin/env bash
set -euo pipefail
KEY="flm_…"
HOST="https://flimmer.example.com"
curl -s -H "Authorization: Bearer $KEY" \
"$HOST/api/v1/bibliothek?status=WATCHING&typ=SERIES" \
| jq -r '.items[] | "\(.title): \(.watched)/\(.total)"'
```
## Grenzen
Es gibt bewusst keine Ratenbegrenzung: Flimmer läuft auf deinem eigenen Server,
und ein Limit gegen dich selbst hilft niemandem. Wer die Instanz öffentlich
erreichbar macht, setzt so etwas besser im Reverse Proxy.
Der Abgleich mit TMDB lässt sich über einen eigenen Endpunkt anstoßen — der
nimmt allerdings das Cron-Geheimnis, nicht einen API-Schlüssel:
```bash
curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
https://flimmer.example.com/api/cron/sync
```
+32 -3
View File
@@ -103,6 +103,30 @@ denselben Variablennamen — keine Änderung an Komponenten.
| `.fl-progress` | Fortschrittsbalken im Signaturverlauf | | `.fl-progress` | Fortschrittsbalken im Signaturverlauf |
| `.fl-glimmer` | Mikro-Animation beim Abhaken einer Episode | | `.fl-glimmer` | Mikro-Animation beim Abhaken einer Episode |
## Diagrammfarben
Die Signaturfarben sind für Diagrammflächen auf dunklem Grund zu hell. Für die
beiden Serien der Statistik stehen deshalb abgedunkelte Varianten:
| Serie | Wert | Prüfung |
|---|---|---|
| Episoden | `#c47a18` | Helligkeitsband, Sättigung, Kontrast ≥ 3:1 — in **beiden** Modi |
| Filme | `#d92d78` | dito |
Das schlechteste Paar erreicht ΔE 12,1 unter Deuteranopie und 20,6 bei
normalem Sehen (Ziel: ≥ 8 bzw. ≥ 15). Beide Werte gelten auf hellem wie
dunklem Grund, weshalb es nur **eine** Diagrammpalette gibt statt zweier.
Balken ohne Identitätsbedeutung (Genre-Verteilung) tragen einheitlich
`#c47a18` — die Länge zeigt schon die Größe, die Farbe muss nichts
wiederholen. Streamingdienste tragen ihre Markenfarbe, aber nur, wenn sie auf
hellem *und* dunklem Grund mindestens 3:1 erreicht; sonst fällt der Balken auf
den geprüften Standardton zurück.
Die Aussage hängt nie allein an der Farbe: Legende, Achsenwerte, ein Tooltip
beim Überfahren und eine für Screenreader hinterlegte Tabelle tragen dieselbe
Information.
## Bewegung ## Bewegung
Ruhig und kurz: 200–420 ms, `cubic-bezier(0.22, 1, 0.36, 1)`. Keine harten Ruhig und kurz: 200–420 ms, `cubic-bezier(0.22, 1, 0.36, 1)`. Keine harten
@@ -111,9 +135,14 @@ verklingt. `prefers-reduced-motion: reduce` schaltet sämtliche Übergänge ab.
## Typografie ## Typografie
**Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Wird beim Bauen **Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Die variable
heruntergeladen und mit ausgeliefert; die laufende Instanz fragt nie bei Google Fassung (Strichstärke 100–900) liegt als Datei im Repository
an. Rückfallkette: Inter, dann System-Sans. (`public/fonts/`), aufgeteilt in Latin und Latin-Extended.
Bewusst nicht `next/font/google`: das lädt die Schrift beim Bauen herunter und
macht den Build von einer Netzverbindung abhängig — im Container blieb er
daran hängen. Mit der mitgelieferten Datei gibt es weder beim Bauen noch zur
Laufzeit einen Abruf nach außen. Rückfallkette: Inter, dann System-Sans.
## Grundhaltung ## Grundhaltung
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

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

+2 -6
View File
@@ -14,12 +14,8 @@ const nextConfig: NextConfig = {
}, },
poweredByHeader: false, poweredByHeader: false,
serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"], serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"],
// Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über // Bilder laufen über den eigenen Cache-Endpunkt (/api/image), nicht über
// next/image-Remote-Loader — deshalb hier keine remotePatterns nötig. // next/image — deshalb ist hier nichts zu konfigurieren.
images: {
dangerouslyAllowSVG: true,
contentDispositionType: "attachment",
},
async headers() { async headers() {
return [ return [
{ {
+298 -14
View File
@@ -1,16 +1,18 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.1.0", "version": "0.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flimmer", "name": "flimmer",
"version": "0.1.0", "version": "0.7.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3",
"bcryptjs": "3.0.3", "bcryptjs": "3.0.3",
"clsx": "2.1.1", "clsx": "2.1.1",
"date-fns": "4.4.0", "date-fns": "4.4.0",
@@ -25,7 +27,6 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "4.3.3", "@tailwindcss/postcss": "4.3.3",
"@types/bcryptjs": "3.0.0",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/nodemailer": "8.0.1", "@types/nodemailer": "8.0.1",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
@@ -537,6 +538,12 @@
"node": ">=18" "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": { "node_modules/@img/colour": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1136,6 +1143,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@next/env": {
"version": "16.3.4", "version": "16.3.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.4.tgz",
@@ -1282,6 +1295,207 @@
"node": ">= 10" "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": { "node_modules/@prisma/adapter-pg": {
"version": "7.10.0", "version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz",
@@ -1646,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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1945,17 +2184,6 @@
"tailwindcss": "4.3.3" "tailwindcss": "4.3.3"
} }
}, },
"node_modules/@types/bcryptjs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"bcryptjs": "*"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
@@ -2597,6 +2825,20 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -4082,6 +4324,24 @@
], ],
"license": "MIT" "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": { "node_modules/rc9": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
@@ -4128,6 +4388,12 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/remeda": {
"version": "2.33.4", "version": "2.33.4",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
@@ -4441,6 +4707,24 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "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": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+10 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.3.0", "version": "0.9.0",
"private": true, "private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT", "license": "MIT",
@@ -15,12 +15,15 @@
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"icons": "tsx scripts/generate-icons.ts", "icons": "tsx scripts/generate-icons.ts",
"screenshots": "tsx scripts/screenshots.ts", "screenshots": "node scripts/screenshots.mjs && node scripts/optimize-screenshots.mjs",
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts" "test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts",
"tmdb:stub": "node --import tsx scripts/tmdb-stub.mjs"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "7.10.0", "@prisma/client": "7.10.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3",
"bcryptjs": "3.0.3", "bcryptjs": "3.0.3",
"clsx": "2.1.1", "clsx": "2.1.1",
"date-fns": "4.4.0", "date-fns": "4.4.0",
@@ -35,7 +38,6 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "4.3.3", "@tailwindcss/postcss": "4.3.3",
"@types/bcryptjs": "3.0.0",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/nodemailer": "8.0.1", "@types/nodemailer": "8.0.1",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
@@ -50,5 +52,9 @@
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"
},
"allowScripts": {
"prisma@7.10.0": true,
"@prisma/engines@7.10.0": true
} }
} }
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "LibraryEntry" ADD COLUMN "owned" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "ownedSource" TEXT;
-- CreateIndex
CREATE INDEX "LibraryEntry_userId_owned_idx" ON "LibraryEntry"("userId", "owned");
@@ -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;
+148
View File
@@ -42,6 +42,9 @@ model User {
publicProfile Boolean @default(false) publicProfile Boolean @default(false)
profileSlug String? @unique profileSlug String? @unique
/// Errungenschaften auf der öffentlichen Profilseite zeigen
showAchievements Boolean @default(true)
// Benachrichtigungswünsche // Benachrichtigungswünsche
notifyNewEpisode Boolean @default(true) notifyNewEpisode Boolean @default(true)
notifyRelease Boolean @default(true) notifyRelease Boolean @default(true)
@@ -53,6 +56,9 @@ model User {
sessions Session[] sessions Session[]
apiKeys ApiKey[] apiKeys ApiKey[]
oidcAccounts OidcAccount[] oidcAccounts OidcAccount[]
passkeys Passkey[]
mediaServers MediaServerConnection[]
achievements Achievement[]
entries LibraryEntry[] entries LibraryEntry[]
watches Watch[] watches Watch[]
tags Tag[] tags Tag[]
@@ -109,6 +115,37 @@ model OidcAccount {
@@index([userId]) @@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 …) /// Instanzweite Einstellungen (Registrierung offen/zu, Instanzname …)
model AppSetting { model AppSetting {
key String @id key String @id
@@ -175,6 +212,7 @@ model Title {
watches Watch[] watches Watch[]
providers TitleProvider[] providers TitleProvider[]
universeItems UniverseItem[] universeItems UniverseItem[]
serverItems MediaServerItem[]
@@unique([type, tmdbId]) @@unique([type, tmdbId])
@@index([type]) @@index([type])
@@ -219,6 +257,7 @@ model Episode {
runtime Int? runtime Int?
watches Watch[] watches Watch[]
serverItems MediaServerItem[]
@@unique([titleId, seasonNumber, episodeNumber]) @@unique([titleId, seasonNumber, episodeNumber])
@@index([titleId]) @@index([titleId])
@@ -289,6 +328,13 @@ model LibraryEntry {
/// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar /// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar
whereToWatch String? whereToWatch String?
/// Liegt der Titel in der eigenen Sammlung? Unabhängig davon, ob ihn gerade
/// ein Streamingdienst führt — gekaufte Filme laufen irgendwann nirgends mehr.
owned Boolean @default(false)
/// Wo genau: „Jellyfin", „Blu-ray", „NAS", „Plex" …
ownedSource String?
favorite Boolean @default(false) favorite Boolean @default(false)
/// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch … /// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch …
@@ -309,6 +355,7 @@ model LibraryEntry {
@@unique([userId, titleId]) @@unique([userId, titleId])
@@index([userId, status]) @@index([userId, status])
@@index([userId, updatedAt]) @@index([userId, updatedAt])
@@index([userId, owned])
@@index([deletedAt]) @@index([deletedAt])
} }
@@ -351,6 +398,11 @@ model Watch {
run Int @default(0) run Int @default(0)
watchedAt DateTime @default(now()) 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 /// Tatsächlich angerechnete Minuten — friert die Laufzeit zum Zeitpunkt der
/// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen. /// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen.
minutes Int @default(0) minutes Int @default(0)
@@ -508,3 +560,99 @@ model SyncRun {
@@index([kind, startedAt]) @@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])
}
+86 -3
View File
@@ -244,6 +244,9 @@ type EntryPlan = {
startedDaysAgo?: number; startedDaysAgo?: number;
/** Abstand zwischen zwei Episoden in Tagen. */ /** Abstand zwischen zwei Episoden in Tagen. */
spacingDays?: number; spacingDays?: number;
/** Liegt der Titel in der eigenen Sammlung? */
owned?: boolean;
ownedSource?: string;
}; };
const ENTRY_PLANS: EntryPlan[] = [ const ENTRY_PLANS: EntryPlan[] = [
@@ -315,8 +318,8 @@ const ENTRY_PLANS: EntryPlan[] = [
}, },
{ key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] }, { key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] },
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340 }, { key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340, owned: true, ownedSource: "Jellyfin" },
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320 }, { key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320, owned: true, ownedSource: "Jellyfin" },
{ key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 }, { key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 },
{ key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] }, { key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] },
{ key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] }, { key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] },
@@ -330,7 +333,7 @@ const ENTRY_PLANS: EntryPlan[] = [
startedDaysAgo: 180, startedDaysAgo: 180,
}, },
{ key: "stille-bilder-2", status: "WATCHLIST" }, { key: "stille-bilder-2", status: "WATCHLIST" },
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60 }, { key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60, owned: true, ownedSource: "Blu-ray" },
{ key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 }, { key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 },
{ key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 }, { key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 },
]; ];
@@ -355,6 +358,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
rating: plan.rating ?? null, rating: plan.rating ?? null,
notes: plan.notes ?? null, notes: plan.notes ?? null,
favorite: plan.favorite ?? false, favorite: plan.favorite ?? false,
owned: plan.owned ?? false,
ownedSource: plan.ownedSource ?? null,
currentRun, currentRun,
startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null, startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null,
finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null, finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null,
@@ -364,6 +369,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
rating: plan.rating ?? null, rating: plan.rating ?? null,
notes: plan.notes ?? null, notes: plan.notes ?? null,
favorite: plan.favorite ?? false, favorite: plan.favorite ?? false,
owned: plan.owned ?? false,
ownedSource: plan.ownedSource ?? null,
currentRun, currentRun,
}, },
}); });
@@ -458,6 +465,73 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
} }
} }
// ---------------------------------------------------------------------------
// Universum
// ---------------------------------------------------------------------------
/**
* Die Erscheinungsreihenfolge berechnet Flimmer selbst — hier steht nur die
* abweichende Story-Chronologie, damit die Seite zwei echte Reihenfolgen zeigt.
*/
const CHRONOLOGICAL = [
"sternenpfad-null",
"sternenpfad-1",
"sternenpfad-akademie",
"sternenpfad-2",
"sternenpfad-serie",
"sternenpfad-3",
"sternenpfad-4",
];
async function seedUniverse(ownerId: string, titleIds: Map<string, string>) {
const existing = await prisma.universe.findUnique({
where: { ownerId_slug: { ownerId, slug: "sternenpfad" } },
select: { id: true },
});
if (existing) {
await prisma.universe.delete({ where: { id: existing.id } });
}
const universe = await prisma.universe.create({
data: {
ownerId,
name: "Sternenpfad",
slug: "sternenpfad",
description:
"Fünf Filme und zwei Serien. Die Kinoreihenfolge und die Story-Chronologie gehen auseinander — deshalb zwei Reihenfolgen.",
orders: {
create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }],
},
},
select: { id: true },
});
const itemIdByKey = new Map<string, string>();
for (const key of CHRONOLOGICAL) {
const titleId = titleIds.get(key);
if (!titleId) continue;
const item = await prisma.universeItem.create({
data: { universeId: universe.id, titleId },
select: { id: true },
});
itemIdByKey.set(key, item.id);
}
const order = await prisma.universeOrder.create({
data: { universeId: universe.id, name: "Chronologisch", kind: "CUSTOM" },
select: { id: true },
});
await prisma.universeOrderItem.createMany({
data: CHRONOLOGICAL.flatMap((key, index) => {
const itemId = itemIdByKey.get(key);
return itemId ? [{ orderId: order.id, itemId, position: index }] : [];
}),
});
return universe;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function main() { async function main() {
@@ -509,6 +583,15 @@ async function main() {
console.log("Seed: Bibliothek und Sichtungen ..."); console.log("Seed: Bibliothek und Sichtungen ...");
await seedLibrary(admin.id, titleIds); await seedLibrary(admin.id, titleIds);
console.log("Seed: Universum ...");
const universe = await seedUniverse(admin.id, titleIds);
// Mit dem zweiten Konto teilen, damit die Freigabe sichtbar ist.
await prisma.universeMember.upsert({
where: { universeId_userId: { universeId: universe.id, userId: friend.id } },
create: { universeId: universe.id, userId: friend.id, permission: "READ" },
update: {},
});
const watches = await prisma.watch.count({ where: { userId: admin.id } }); const watches = await prisma.watch.count({ where: { userId: admin.id } });
const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } }); const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } });
Binary file not shown.
Binary file not shown.
+108
View File
@@ -0,0 +1,108 @@
/**
* Flimmer — Service Worker
*
* Bewusst handgeschrieben und knapp gehalten. Drei Strategien:
*
* Bilder und statische Dateien → zuerst aus dem Zwischenspeicher.
* Poster ändern sich unter einem TMDB-Pfad nie, das spart jede Anfrage.
* Seitenaufrufe → zuerst das Netz, sonst der Speicher,
* sonst die Offline-Seite. Ein Tracker, der veraltete Stände zeigt, wäre
* schlimmer als einer, der ehrlich sagt: gerade keine Verbindung.
* Alles Übrige (API, Aktionen) → gar kein Zwischenspeicher.
*
* Schreibende Anfragen laufen nie über den Service Worker.
*/
const VERSION = "flimmer-v1";
const SHELL_CACHE = `${VERSION}-shell`;
const IMAGE_CACHE = `${VERSION}-images`;
const PAGE_CACHE = `${VERSION}-pages`;
const OFFLINE_URL = "/offline";
const SHELL = [OFFLINE_URL, "/favicon.svg", "/icons/icon-192.png", "/manifest.webmanifest"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(SHELL_CACHE)
.then((cache) => cache.addAll(SHELL))
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => !key.startsWith(VERSION)).map((key) => caches.delete(key))),
)
.then(() => self.clients.claim()),
);
});
/** Zwischenspeicher begrenzen, damit Poster ihn nicht endlos füllen. */
async function trim(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length <= maxEntries) return;
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
}
async function cacheFirst(request, cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok) {
await cache.put(request, response.clone());
void trim(cacheName, maxEntries);
}
return response;
}
async function networkFirst(request) {
const cache = await caches.open(PAGE_CACHE);
try {
const response = await fetch(request);
if (response.ok) {
await cache.put(request, response.clone());
void trim(PAGE_CACHE, 40);
}
return response;
} catch (error) {
const hit = await cache.match(request);
if (hit) return hit;
const offline = await caches.match(OFFLINE_URL);
if (offline) return offline;
throw error;
}
}
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Bilder aus dem eigenen Cache-Endpunkt und statische Next-Dateien
if (url.pathname.startsWith("/api/image")) {
event.respondWith(cacheFirst(request, IMAGE_CACHE, 400));
return;
}
if (url.pathname.startsWith("/_next/static") || url.pathname.startsWith("/icons/")) {
event.respondWith(cacheFirst(request, SHELL_CACHE, 200));
return;
}
// Übrige API-Aufrufe nie zwischenspeichern
if (url.pathname.startsWith("/api/")) return;
if (request.mode === "navigate") {
event.respondWith(networkFirst(request));
}
});
+35
View File
@@ -0,0 +1,35 @@
# Flimmer 0.4.0
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
### Hinzugefügt
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
Wizarding World oder was auch immer zusammengehört.
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig.
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
vorkommen, lassen sich mit einem Klick übernehmen.
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
Serien hinweg, Freigaben und die Rechteprüfung.
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
Serien und einer abweichenden Story-Chronologie.
### Behoben
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
serverseitig.
+45
View File
@@ -0,0 +1,45 @@
# Flimmer 0.5.0
Übersicht, Kalender und Statistik.
### Hinzugefügt
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
verfügbar geworden sind.
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
zählen mit.
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
Standardton.
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
„Weiterschauen" und den Kalenderzeitraum.
### Behoben
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
(„2026-04") aus der Datenbank.
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
hängt jetzt als `aria-label` am `<svg>`.
+46
View File
@@ -0,0 +1,46 @@
# Flimmer 0.6.0
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
— und eine bebilderte Anleitung.
### Hinzugefügt
- **Benachrichtigungen** über vier Wege: E-Mail (SMTP), ntfy, Gotify und ein
generischer Webhook, der zugleich Discord, Slack und Mattermost bedient.
Jeder Nutzer richtet seine Kanäle selbst ein, kann sie einzeln stummschalten
und mit einer Testnachricht ausprobieren. Dieselbe Meldung geht nie zweimal
raus — auch ein Fehlversuch wird protokolliert, sonst spammt der nächste Lauf
den Kanal voll, sobald er wieder erreichbar ist. Die E-Mail-Vorlage trägt
Logo und Farbwelt von Flimmer.
- **Täglicher Abgleich**: frischt laufende Serien bei TMDB auf, meldet neue
Episoden und neu verfügbare Watchlist-Titel, räumt Papierkorb und abgelaufene
Sitzungen. Eingebauter Zeitplan über `ENABLE_SCHEDULER` und `SYNC_HOUR`, von
außen anstoßbar über `/api/cron/sync` mit `CRON_SECRET`, und in der
Verwaltung per Knopfdruck. Ein einzelner Titel kann den Lauf nicht kippen.
- **Import** aus Trakt.tv (JSON, egal ob Watchlist, History, Ratings oder
Collection) und aus CSV mit Komma oder Semikolon. Der Import überschreibt
nichts: was schon in der Bibliothek liegt, bleibt unangetastet.
- **Export** der eigenen Daten als CSV oder als JSON mit allen Sichtungen und
Universen.
- **REST-API** unter `/api/v1` mit persönlichen API-Schlüsseln: Bibliothek
lesen und pflegen, Episoden abhaken und zurücknehmen, suchen, Kalender,
Statistik, Universen, Export. Beschreibung als OpenAPI 3.1 unter
`/api/v1/openapi.json`, dazu `docs/API.md` mit Beispielen.
- **Einstellungsseiten** für Profil, Region, Passwort, Benachrichtigungen,
API-Schlüssel sowie Import und Export.
- **Verwaltungsbereich**: Instanzname, Registrierung öffnen und schließen,
öffentliche Profile erlauben, Konten sperren und Rechte vergeben, Abgleich
anstoßen und der Zustand der Instanz auf einen Blick. Die letzte
Administration lässt sich nicht entmachten.
- **Öffentliche Profilseite** unter `/u/<adresse>` — Zahlen und zuletzt
Gesehenes, aber keine Notizen, keine Watchlist, keine E-Mail-Adresse.
Abschaltbar durch den Nutzer *und* instanzweit durch den Betreiber.
- **PWA-Feinschliff**: eigener Service Worker (Poster aus dem Zwischenspeicher,
Seiten zuerst aus dem Netz, Offline-Rückfall) und eine Offline-Seite.
- **`TMDB_BASE_URL`** für Instanzen hinter einem eigenen Proxy — und für den
mitgelieferten TMDB-Ersatz `scripts/tmdb-stub.mjs`, mit dem sich die Suche
ohne Schlüssel aus den Beispieldaten ausprobieren lässt.
- **Bebilderte Anleitung** `docs/ANLEITUNG.md` mit 18 Screenshots aus der
laufenden Instanz, aufgenommen von `npm run screenshots`.
- **9 weitere Tests** für CSV- und Trakt-Import, Export, das Aufräumen des
Papierkorbs und den Schutz vor Doppelversand — zusammen 49.
+59
View File
@@ -0,0 +1,59 @@
# Flimmer 0.7.0
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
gestartet, und dabei kamen Fehler ans Licht, die nur der echte Containerstart
zeigt.
### Hinzugefügt
- **Eigene Sammlung**: Jeder Eintrag lässt sich als „hab ich" markieren, mit
Angabe wo — Jellyfin, Plex, Emby, NAS, Blu-ray, DVD oder frei getippt.
Bewusst getrennt vom Feld „wo schaue ich das": Streaming-Verfügbarkeit kommt
und geht, der gekaufte Film auf dem Heimserver bleibt. Gerade die Titel, die
nirgends mehr laufen, will man ja wiederfinden.
- Abzeichen auf der Posterkachel und im Kopf der Detailseite
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit
— was man hat, ist die verlässlichere Antwort auf „wo kann ich das schauen"
- Eigener Filter in der Bibliothek samt Zähler
- In Export (CSV und JSON), CSV-Import und REST-API (`?sammlung=1` beim
Lesen, `owned` und `ownedSource` beim Ändern)
### Behoben
- **Der Container konnte seine Migrationen nicht einspielen.** Das Laufzeitimage
bekam die Prisma-CLI, indem einzelne Ordner aus `node_modules` kopiert wurden
— dabei fehlten transitive Abhängigkeiten von `@prisma/config` (`effect` und
weitere). Die CLI wird jetzt in einer eigenen Build-Stufe vollständig
installiert und liegt im Laufzeitimage unter `/node_modules`, wo sie den
getracten Abhängigkeitsbaum der Anwendung nicht stört.
- **Der Build hing im Container am Schriftabruf.** `next/font/google` lädt
Outfit beim Bauen von Google herunter — das macht den Build von einer
Netzverbindung abhängig und blieb im Container hängen. Die variable Fassung
liegt jetzt als Datei im Repository; weder beim Bauen noch zur Laufzeit geht
eine Anfrage nach außen. Für eine selbst gehostete Anwendung ist das ohnehin
das Richtige.
- **npm blockierte Prismas Install-Skripte** (Voreinstellung seit npm 11),
wodurch im Image die plattformpassende Engine fehlen konnte. Sie sind jetzt
gezielt für `prisma` und `@prisma/engines` erlaubt statt pauschal.
### Hinzugefügt
- **Erscheinungsmeldung für Watchlist-Filme.** Der Schalter dafür stand schon im
Profil, verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der
letzten Tage statt eines Zustandswechsels — TMDB verschiebt Termine
gelegentlich, und ein einmal verpasstes Datum soll die Meldung nicht für
immer verhindern.
- **Gitea-Actions-Workflows**: `ci.yml` prüft bei jedem Push Typen, Tests und
Produktionsbuild gegen eine echte PostgreSQL-Instanz; `release.yml` legt beim
Pushen eines Versions-Tags das Release mit den Notizen aus
`releases/vX.Y.Z.md` an.
- Diagrammfarben und ihre Prüfwerte in `docs/DESIGN.md` festgehalten; das
öffentliche Profil in die Anleitung aufgenommen.
### Entfernt
- Die ungenutzte `images`-Konfiguration in `next.config.ts`. `next/image` kommt
nirgends zum Einsatz — damit entfällt auch das sicherheitsrelevant aussehende
`dangerouslyAllowSVG`, das ohnehin wirkungslos war.
+25
View File
@@ -0,0 +1,25 @@
# Flimmer 0.7.1
### Hinzugefügt
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
wieder antwortet.
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
Weitere Eigenheiten:
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
unterbleibt dann, statt sie zu überfahren.
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
Healthcheck des Containers.
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
Compose-Datei als Vorgabewert.
+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.
+34
View File
@@ -0,0 +1,34 @@
import sharp from "sharp";
import { readdir, unlink, stat } from "node:fs/promises";
import { join } from "node:path";
/**
* Verkleinert die aufgenommenen Screenshots für das Repository.
* Die Aufnahme läuft mit doppelter Pixeldichte (scharfe Schrift), abgelegt
* wird die halbe Kantenlänge als WebP — das spart rund 90 Prozent.
*/
const DIR = "docs/screenshots";
const files = (await readdir(DIR)).filter((name) => name.endsWith(".png"));
let before = 0;
let after = 0;
for (const name of files) {
const source = join(DIR, name);
before += (await stat(source)).size;
const image = sharp(source);
const meta = await image.metadata();
const target = join(DIR, name.replace(/\.png$/, ".webp"));
await image
.resize({ width: Math.round((meta.width ?? 1400) / 2) })
.webp({ quality: 82, effort: 5 })
.toFile(target);
after += (await stat(target)).size;
await unlink(source);
}
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
console.log(`${files.length} Bilder: ${mb(before)} MB -> ${mb(after)} MB`);
+235
View File
@@ -0,0 +1,235 @@
import { chromium } from "playwright";
import fs from "node:fs";
import { createServer } from "node:http";
const OUT = "docs/screenshots";
const BASE = "http://localhost:3011";
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch();
const errs = [];
async function session(width, height, theme) {
const ctx = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: 2,
colorScheme: theme === "light" ? "light" : "dark",
locale: "de-DE",
timezoneId: "Europe/Berlin",
});
await ctx.addInitScript((mode) => localStorage.setItem("flimmer-theme", mode), theme);
// Der Entwicklungs-Indikator von Next hat in einer Anleitung nichts verloren.
await ctx.addStyleTag?.({ content: "nextjs-portal { display: none !important; }" });
const page = await ctx.newPage();
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
page.on("pageerror", (e) => errs.push(String(e).slice(0, 160)));
return { ctx, page };
}
async function login(page) {
await page.goto(`${BASE}/anmelden`);
await page.fill("#email", "demo@flimmer.local");
await page.fill("#password", "flimmer-demo-2026");
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(() => {});
await page.waitForTimeout(opts.wait ?? 900);
if (opts.before) await opts.before(page);
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: Boolean(opts.full) });
console.log(" ", name);
}
// --- Anmeldung (ohne Sitzung) ---
{
const { ctx, page } = await session(1280, 800, "dark");
await shot(page, "01-anmeldung", "/anmelden");
await ctx.close();
}
// --- Angemeldet, dunkel ---
{
const { ctx, page } = await session(1400, 900, "dark");
await login(page);
await shot(page, "02-uebersicht", "/", { full: true });
await shot(page, "03-suche", "/suche?q=sternenpfad");
await shot(page, "05-bibliothek", "/bibliothek");
await shot(page, "09-kalender", "/kalender");
await shot(page, "10-statistik", "/statistik", { full: true });
await shot(page, "11-einstellungen", "/einstellungen");
await shot(page, "12-benachrichtigungen", "/einstellungen/benachrichtigungen");
await shot(page, "13-api", "/einstellungen/api");
await shot(page, "14-daten", "/einstellungen/daten");
await shot(page, "15-verwaltung", "/verwaltung", { full: true });
await shot(page, "16-papierkorb", "/papierkorb");
// Detailseite und Universum brauchen IDs aus der Bibliothek
await page.goto(`${BASE}/bibliothek?q=nordlicht`, { waitUntil: "networkidle" });
const titleHref = await page.locator("a[href^='/titel/']").first().getAttribute("href");
if (titleHref) await shot(page, "04-serie", titleHref, { full: true });
await page.goto(`${BASE}/universen`, { waitUntil: "networkidle" });
await shot(page, "06-universen", "/universen");
const uniHref = await page.locator("a[href^='/universen/']").first().getAttribute("href");
if (uniHref) await shot(page, "07-universum", uniHref, { full: true });
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");
await login(page);
await shot(page, "08-hell", "/bibliothek");
await ctx.close();
}
// --- Mobil ---
{
const { ctx, page } = await session(430, 900, "dark");
await login(page);
await shot(page, "17-mobil-uebersicht", "/");
await shot(page, "18-mobil-serie", "/bibliothek");
await ctx.close();
}
console.log("Fehler:", errs.length ? [...new Set(errs)].slice(0, 5) : "keine");
await browser.close();
+98
View File
@@ -0,0 +1,98 @@
/**
* Kleiner TMDB-Ersatz für Screenshots und manuelles Ausprobieren.
*
* node scripts/tmdb-stub.mjs [port]
*
* Beantwortet die Suchendpunkte aus dem lokalen Katalog (den das Seed-Skript
* angelegt hat) im Format der TMDB-API. Damit lässt sich die Suchseite auch
* ohne echten TMDB-Schlüssel zeigen — für Screenshots der Anleitung und um
* die Oberfläche auszuprobieren, bevor man einen Schlüssel besorgt.
*
* Verwendung zusammen mit:
* TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
*/
import { createServer } from "node:http";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client.ts";
try {
process.loadEnvFile(".env");
} catch {
// Werte kommen aus der Umgebung.
}
const port = Number(process.argv[2] ?? 3099);
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
function toResult(title, mediaType) {
const date = title.releaseDate?.toISOString().slice(0, 10) ?? null;
return {
id: title.tmdbId,
media_type: mediaType,
name: mediaType === "tv" ? title.title : undefined,
title: mediaType === "movie" ? title.title : undefined,
original_name: title.originalTitle ?? undefined,
original_title: title.originalTitle ?? undefined,
overview: title.overview ?? "",
poster_path: title.posterPath,
backdrop_path: title.backdropPath,
first_air_date: mediaType === "tv" ? date : undefined,
release_date: mediaType === "movie" ? date : undefined,
vote_average: title.voteAverage ?? 0,
};
}
async function search(query, type) {
const titles = await prisma.title.findMany({
where: {
tmdbId: { not: null },
...(type ? { type } : {}),
OR: [
{ title: { contains: query, mode: "insensitive" } },
{ originalTitle: { contains: query, mode: "insensitive" } },
{ genres: { has: query } },
],
},
orderBy: { releaseDate: "desc" },
take: 20,
});
return titles.map((title) => toResult(title, title.type === "SERIES" ? "tv" : "movie"));
}
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
const query = url.searchParams.get("query") ?? "";
const send = (body, status = 200) => {
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify(body));
};
try {
if (url.pathname.startsWith("/3/search/")) {
const kind = url.pathname.split("/").pop();
const type = kind === "tv" ? "SERIES" : kind === "movie" ? "MOVIE" : null;
const results = await search(query, type);
return send({ page: 1, results, total_pages: 1, total_results: results.length });
}
return send({ status_message: `Kein Ersatz für ${url.pathname}` }, 404);
} catch (error) {
return send({ status_message: String(error) }, 500);
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`TMDB-Ersatz läuft auf http://127.0.0.1:${port}/3`);
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
server.close();
void prisma.$disconnect();
process.exit(0);
});
}
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from "playwright";
const [,, SP, ...specs] = process.argv;
const b = await chromium.launch();
const c = await b.newContext({ viewport: { width: 1280, height: 950 }, deviceScaleFactor: 2, colorScheme: "dark" });
const p = await c.newPage();
const errs = [];
p.on("pageerror", e => errs.push(String(e).slice(0,200)));
p.on("console", m => { if (m.type()==="error") errs.push("console: "+m.text().slice(0,160)); });
await p.goto("http://localhost:3011/anmelden");
await p.fill("#email", "demo@flimmer.local");
await p.fill("#password", "flimmer-demo-2026");
await Promise.all([p.waitForURL("http://localhost:3011/"), p.click("button[type=submit]")]);
for (const spec of specs) {
const [name, url, full] = spec.split("|");
await p.goto(`http://localhost:3011${url}`, { waitUntil: "networkidle" });
await p.waitForTimeout(900);
await p.screenshot({ path: `${SP}/${name}.png`, fullPage: full === "full" });
}
console.log("Fehler:", errs.length ? errs.slice(0,4) : "keine");
await b.close();
+9 -2
View File
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/lib/auth";
import { getSettings, isRegistrationOpen } from "@/lib/settings"; import { getSettings, isRegistrationOpen } from "@/lib/settings";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { AuthSwitch, LoginForm } from "@/components/AuthForm"; import { AuthSwitch, LoginForm } from "@/components/AuthForm";
import { PasskeyLoginButton } from "@/components/settings/Passkeys";
import { loginAction } from "../actions"; import { loginAction } from "../actions";
export const metadata: Metadata = { title: "Anmelden" }; export const metadata: Metadata = { title: "Anmelden" };
@@ -36,16 +37,22 @@ export default async function LoginPage({
<LoginForm action={loginAction} weiter={params.weiter} /> <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)]"> <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)]" /> <span className="h-px flex-1 bg-[var(--fl-border)]" />
oder oder
<span className="h-px flex-1 bg-[var(--fl-border)]" /> <span className="h-px flex-1 bg-[var(--fl-border)]" />
</div> </div>
<a href="/api/auth/oidc/start" className="fl-btn fl-btn-ghost w-full">
<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} {env.oidc.buttonLabel}
</a> </a>
)}
</div>
</> </>
)} )}
+42 -9
View File
@@ -17,12 +17,14 @@ import {
startRewatch, startRewatch,
undoWatches, undoWatches,
unmarkEpisodes, unmarkEpisodes,
unmarkMovie,
unmarkSeason, unmarkSeason,
updateEntryDetails, updateEntryDetails,
watchNextEpisode, watchNextEpisode,
type WatchResult, type WatchResult,
} from "@/lib/library"; } from "@/lib/library";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { achievementName, evaluateAchievements } from "@/lib/achievements";
/** /**
* Server-Aktionen rund um die Bibliothek. * Server-Aktionen rund um die Bibliothek.
@@ -37,11 +39,14 @@ export type ActionResult = {
message?: string; message?: string;
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */ /** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
watchIds?: string[]; watchIds?: string[];
/** Gerade erreichte Errungenschaften, fertig benannt. */
achievements?: string[];
}; };
async function run( async function run(
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>, fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
revalidate: string[] = [], revalidate: string[] = [],
{ pruefeErrungenschaften = false }: { pruefeErrungenschaften?: boolean } = {},
): Promise<ActionResult> { ): Promise<ActionResult> {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." }; if (!user) return { ok: false, message: "Bitte melde dich an." };
@@ -49,7 +54,25 @@ async function run(
try { try {
const result = (await fn(user.id)) ?? {}; const result = (await fn(user.id)) ?? {};
for (const path of revalidate) revalidatePath(path, "layout"); 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) { } catch (error) {
if (error instanceof LibraryError) return { ok: false, message: error.message }; if (error instanceof LibraryError) return { ok: false, message: error.message };
console.error("Bibliotheksaktion fehlgeschlagen:", error); console.error("Bibliotheksaktion fehlgeschlagen:", error);
@@ -74,7 +97,7 @@ export async function addTitleAction(titleId: string, status: string = "WATCHLIS
message: message:
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt", parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
}; };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function trashEntryAction(entryId: string) { export async function trashEntryAction(entryId: string) {
@@ -114,6 +137,8 @@ const detailsSchema = z.object({
notes: z.string().max(5000).nullable().optional(), notes: z.string().max(5000).nullable().optional(),
whereToWatch: z.string().max(120).nullable().optional(), whereToWatch: z.string().max(120).nullable().optional(),
favorite: z.boolean().optional(), favorite: z.boolean().optional(),
owned: z.boolean().optional(),
ownedSource: z.string().max(80).nullable().optional(),
}); });
export async function updateEntryAction(entryId: string, data: unknown) { export async function updateEntryAction(entryId: string, data: unknown) {
@@ -127,7 +152,7 @@ export async function startRewatchAction(entryId: string) {
return run(async (userId) => { return run(async (userId) => {
const entry = await startRewatch(userId, cuid.parse(entryId)); const entry = await startRewatch(userId, cuid.parse(entryId));
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` }; return { message: `Durchlauf ${entry.currentRun + 1} gestartet` };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -145,7 +170,7 @@ export async function toggleEpisodeAction(entryId: string, episodeId: string, wa
} }
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]); await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
return { message: "Markierung entfernt" }; return { message: "Markierung entfernt" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) { export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
@@ -155,29 +180,37 @@ export async function toggleSeasonAction(entryId: string, seasonId: string, watc
} }
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId)); await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
return { message: "Staffel zurückgesetzt" }; return { message: "Staffel zurückgesetzt" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function markUpToAction(entryId: string, episodeId: string) { export async function markUpToAction(entryId: string, episodeId: string) {
return run( return run(
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))), async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
PATHS, PATHS,
{ pruefeErrungenschaften: true },
); );
} }
export async function watchNextAction(entryId: string) { 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) { 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[]) { export async function undoAction(entryId: string, watchIds: string[]) {
return run(async (userId) => { return run(async (userId) => {
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds)); await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
return { message: "Rückgängig gemacht" }; return { message: "Rückgängig gemacht" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -224,7 +257,7 @@ export async function setTagsAction(entryId: string, tagNames: string[]) {
]); ]);
return { message: "Tags gespeichert" }; return { message: "Tags gespeichert" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+461
View File
@@ -0,0 +1,461 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
checkPasswordStrength,
createApiKey,
destroyAllSessions,
getSessionUser,
hashPassword,
verifyPassword,
} from "@/lib/auth";
import { prisma } from "@/lib/db";
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. */
export type SettingsResult = { ok: boolean; message?: string; secret?: string };
async function run(
fn: (userId: string) => Promise<{ message?: string; secret?: string } | void>,
): Promise<SettingsResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
try {
const result = (await fn(user.id)) ?? {};
revalidatePath("/einstellungen", "layout");
revalidatePath("/", "layout");
return { ok: true, ...result };
} catch (error) {
console.error("Einstellungs-Aktion fehlgeschlagen:", error);
return {
ok: false,
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
};
}
}
async function requireAdminUser() {
const user = await getSessionUser();
if (!user || user.role !== "ADMIN") {
throw new Error("Dafür fehlen dir die Rechte.");
}
return user;
}
// ---------------------------------------------------------------------------
// Profil
// ---------------------------------------------------------------------------
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()
.toLowerCase()
.regex(/^[a-z0-9-]{3,40}$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 40 Zeichen.")
.nullable(),
notifyNewEpisode: z.boolean(),
notifyRelease: z.boolean(),
notifyAvailability: z.boolean(),
});
export async function updateProfileAction(data: unknown) {
return run(async (userId) => {
const parsed = profileSchema.parse(data);
if (parsed.publicProfile && !parsed.profileSlug) {
throw new Error("Für ein öffentliches Profil braucht es eine Adresse.");
}
if (parsed.profileSlug) {
const taken = await prisma.user.findFirst({
where: { profileSlug: parsed.profileSlug, id: { not: userId } },
select: { id: true },
});
if (taken) throw new Error("Diese Profiladresse ist schon vergeben.");
}
await prisma.user.update({ where: { id: userId }, data: parsed });
return { message: "Gespeichert" };
});
}
const passwordSchema = z.object({
current: z.string().min(1),
next: z.string().min(1),
repeat: z.string().min(1),
});
export async function changePasswordAction(data: unknown) {
return run(async (userId) => {
const parsed = passwordSchema.parse(data);
if (parsed.next !== parsed.repeat) throw new Error("Die neuen Passwörter stimmen nicht überein.");
const weak = checkPasswordStrength(parsed.next);
if (weak) throw new Error(weak);
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { passwordHash: true },
});
if (!user.passwordHash) {
throw new Error("Dieses Konto meldet sich über SSO an und hat kein Passwort.");
}
if (!(await verifyPassword(parsed.current, user.passwordHash))) {
throw new Error("Das bisherige Passwort stimmt nicht.");
}
await prisma.user.update({
where: { id: userId },
data: { passwordHash: await hashPassword(parsed.next) },
});
// Nach einem Passwortwechsel fliegen alle anderen Geräte raus.
await destroyAllSessions(userId);
return { message: "Passwort geändert — bitte neu anmelden." };
});
}
// ---------------------------------------------------------------------------
// API-Schlüssel
// ---------------------------------------------------------------------------
export async function createApiKeyAction(name: string) {
return run(async (userId) => {
const clean = z.string().trim().min(1).max(60).parse(name);
const { plain } = await createApiKey(userId, clean);
// Der Klartext existiert nur hier — danach steht in der Datenbank nur der Hash.
return { message: "Schlüssel angelegt", secret: plain };
});
}
export async function revokeApiKeyAction(keyId: string) {
return run(async (userId) => {
await prisma.apiKey.updateMany({
where: { id: keyId, userId },
data: { revokedAt: new Date() },
});
return { message: "Schlüssel zurückgezogen" };
});
}
// ---------------------------------------------------------------------------
// Benachrichtigungskanäle
// ---------------------------------------------------------------------------
const channelSchema = z.object({
kind: z.enum(["EMAIL", "NTFY", "GOTIFY", "WEBHOOK"]),
name: z.string().trim().min(1).max(60),
config: z.record(z.string(), z.string().max(500)).default({}),
});
export async function saveChannelAction(data: unknown, channelId?: string) {
return run(async (userId) => {
const parsed = channelSchema.parse(data);
if (channelId) {
await prisma.notificationChannel.updateMany({
where: { id: channelId, userId },
data: { kind: parsed.kind, name: parsed.name, config: parsed.config },
});
return { message: "Kanal gespeichert" };
}
await prisma.notificationChannel.create({
data: { userId, kind: parsed.kind, name: parsed.name, config: parsed.config },
});
return { message: "Kanal angelegt" };
});
}
export async function toggleChannelAction(channelId: string, enabled: boolean) {
return run(async (userId) => {
await prisma.notificationChannel.updateMany({
where: { id: channelId, userId },
data: { enabled },
});
return { message: enabled ? "Kanal aktiv" : "Kanal ruht" };
});
}
export async function deleteChannelAction(channelId: string) {
return run(async (userId) => {
await prisma.notificationChannel.deleteMany({ where: { id: channelId, userId } });
return { message: "Kanal entfernt" };
});
}
export async function testChannelAction(channelId: string) {
return run(async (userId) => {
await sendTestNotification(userId, channelId);
return { message: "Testnachricht verschickt" };
});
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
export type ImportResult = { ok: boolean; message?: string; summary?: ImportSummary };
export async function importDataAction(
format: "csv" | "trakt",
content: string,
): Promise<ImportResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
if (content.length > 8_000_000) {
return { ok: false, message: "Die Datei ist zu groß (mehr als 8 MB)." };
}
try {
const summary = format === "csv" ? await importCsv(user.id, content) : await importTrakt(user.id, content);
revalidatePath("/bibliothek", "layout");
revalidatePath("/", "layout");
return {
ok: true,
message: `${summary.added} aufgenommen, ${summary.skipped} übersprungen`,
summary,
};
} catch (error) {
console.error("Import fehlgeschlagen:", error);
return { ok: false, message: error instanceof Error ? error.message : "Import fehlgeschlagen." };
}
}
// ---------------------------------------------------------------------------
// Verwaltung
// ---------------------------------------------------------------------------
export async function setRegistrationAction(enabled: boolean) {
return run(async () => {
await requireAdminUser();
await setSetting("registrationEnabled", enabled);
return { message: enabled ? "Registrierung offen" : "Registrierung geschlossen" };
});
}
export async function setInstanceNameAction(name: string) {
return run(async () => {
await requireAdminUser();
await setSetting("instanceName", z.string().trim().min(1).max(60).parse(name));
return { message: "Name gespeichert" };
});
}
export async function setPublicProfilesAction(enabled: boolean) {
return run(async () => {
await requireAdminUser();
await setSetting("publicProfilesEnabled", enabled);
return { message: enabled ? "Öffentliche Profile erlaubt" : "Öffentliche Profile abgeschaltet" };
});
}
export async function setUserActiveAction(targetId: string, active: boolean) {
return run(async (userId) => {
await requireAdminUser();
if (targetId === userId) throw new Error("Dich selbst kannst du nicht sperren.");
await prisma.user.update({ where: { id: targetId }, data: { isActive: active } });
if (!active) await destroyAllSessions(targetId);
revalidatePath("/verwaltung", "layout");
return { message: active ? "Konto freigegeben" : "Konto gesperrt" };
});
}
export async function setUserRoleAction(targetId: string, role: "ADMIN" | "USER") {
return run(async (userId) => {
await requireAdminUser();
if (targetId === userId) throw new Error("Deine eigene Rolle kannst du nicht ändern.");
if (role === "USER") {
const admins = await prisma.user.count({ where: { role: "ADMIN", isActive: true } });
if (admins <= 1) throw new Error("Es muss mindestens eine Administration geben.");
}
await prisma.user.update({ where: { id: targetId }, data: { role } });
revalidatePath("/verwaltung", "layout");
return { message: role === "ADMIN" ? "Zur Administration ernannt" : "Rechte entzogen" };
});
}
export async function runSyncAction() {
return run(async () => {
await requireAdminUser();
const stats = await runSync({ limit: 40 });
revalidatePath("/verwaltung", "layout");
return {
message:
stats.errors.length === 0
? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen`
: `Mit ${stats.errors.length} Fehlern beendet: ${stats.errors[0]}`,
};
});
}
// ---------------------------------------------------------------------------
// 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" };
});
}
+163
View File
@@ -0,0 +1,163 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getSessionUser } from "@/lib/auth";
import {
addCollection,
addTitle,
createOrder,
createUniverse,
deleteOrder,
removeTitle,
renameOrder,
renameUniverse,
setDefaultOrder,
setOrderPositions,
shareUniverse,
trashUniverse,
unshareUniverse,
UniverseError,
} from "@/lib/universes";
/** Server-Aktionen für Universen. Zugriffsrechte prüft die Domänenschicht. */
export type UniverseActionResult = { ok: boolean; message?: string; id?: string };
async function run(
fn: (userId: string) => Promise<{ message?: string; id?: string } | void>,
): Promise<UniverseActionResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
try {
const result = (await fn(user.id)) ?? {};
revalidatePath("/universen", "layout");
revalidatePath("/", "layout");
return { ok: true, message: result.message, id: result.id };
} catch (error) {
if (error instanceof UniverseError) return { ok: false, message: error.message };
console.error("Universums-Aktion fehlgeschlagen:", error);
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
}
}
const id = z.string().min(1).max(64);
const name = z.string().trim().min(1).max(120);
export async function createUniverseAction(rawName: string, description?: string) {
return run(async (userId) => {
const universe = await createUniverse(userId, name.parse(rawName), description);
return { message: `„${universe.name}" angelegt`, id: universe.id };
});
}
export async function renameUniverseAction(
universeId: string,
data: { name?: string; description?: string | null },
) {
return run(async (userId) => {
await renameUniverse(userId, id.parse(universeId), {
...(data.name !== undefined ? { name: name.parse(data.name) } : {}),
...(data.description !== undefined
? { description: z.string().max(2000).nullable().parse(data.description) }
: {}),
});
return { message: "Gespeichert" };
});
}
export async function trashUniverseAction(universeId: string) {
return run(async (userId) => {
await trashUniverse(userId, id.parse(universeId));
return { message: "Universum gelöscht" };
});
}
export async function addTitleToUniverseAction(universeId: string, titleId: string) {
return run(async (userId) => {
await addTitle(userId, id.parse(universeId), id.parse(titleId));
return { message: "Aufgenommen" };
});
}
export async function removeFromUniverseAction(universeId: string, itemId: string) {
return run(async (userId) => {
await removeTitle(userId, id.parse(universeId), id.parse(itemId));
return { message: "Aus dem Universum entfernt" };
});
}
export async function addCollectionAction(universeId: string, tmdbCollectionId: number) {
return run(async (userId) => {
const added = await addCollection(
userId,
id.parse(universeId),
z.number().int().parse(tmdbCollectionId),
);
return {
message:
added === 0
? "Alles daraus war schon drin"
: `${added} ${added === 1 ? "Titel" : "Titel"} übernommen`,
};
});
}
export async function createOrderAction(universeId: string, rawName: string) {
return run(async (userId) => {
const order = await createOrder(userId, id.parse(universeId), name.parse(rawName));
return { message: "Reihenfolge angelegt", id: order.id };
});
}
export async function renameOrderAction(orderId: string, rawName: string) {
return run(async (userId) => {
await renameOrder(userId, id.parse(orderId), name.parse(rawName));
return { message: "Umbenannt" };
});
}
export async function deleteOrderAction(orderId: string) {
return run(async (userId) => {
await deleteOrder(userId, id.parse(orderId));
return { message: "Reihenfolge gelöscht" };
});
}
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));
return { message: "Reihenfolge gespeichert" };
});
}
export async function shareUniverseAction(
universeId: string,
email: string,
permission: "READ" | "WRITE",
) {
return run(async (userId) => {
const target = await shareUniverse(
userId,
id.parse(universeId),
z.string().trim().email().parse(email),
z.enum(["READ", "WRITE"]).parse(permission),
);
return { message: `Mit ${target.name} geteilt` };
});
}
export async function unshareUniverseAction(universeId: string, memberId: string) {
return run(async (userId) => {
await unshareUniverse(userId, id.parse(universeId), id.parse(memberId));
return { message: "Freigabe aufgehoben" };
});
}
@@ -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 });
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { getSessionUser } from "@/lib/auth";
import { runSync } from "@/lib/sync";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
/**
* Abgleich von außen anstoßen.
*
* curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
* https://flimmer.example/api/cron/sync
*
* Zugelassen ist entweder das Cron-Geheimnis oder eine angemeldete
* Administration — Letzteres für den Knopf im Verwaltungsbereich.
*/
async function authorize(request: Request): Promise<boolean> {
const header = request.headers.get("authorization");
if (env.scheduler.cronSecret && header === `Bearer ${env.scheduler.cronSecret}`) return true;
const user = await getSessionUser();
return user?.role === "ADMIN";
}
export async function POST(request: Request) {
if (!(await authorize(request))) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 401 });
}
const stats = await runSync();
return NextResponse.json({ ok: stats.errors.length === 0, ...stats });
}
/** GET erlaubt einfache Uptime-Cron-Dienste, die kein POST können. */
export async function GET(request: Request) {
return POST(request);
}
+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));
}
@@ -0,0 +1,89 @@
import { z } from "zod";
import { apiError, readJson, withApi } from "@/lib/api";
import { prisma } from "@/lib/db";
import {
getNextEpisode,
getProgress,
moveToTrash,
setStatus,
updateEntryDetails,
} from "@/lib/library";
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const;
/** GET /api/v1/bibliothek/{id} — ein Eintrag mit Fortschritt und nächster Episode. */
export const GET = withApi(async (_request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const entry = await prisma.libraryEntry.findFirst({
where: { id: entryId, userId: user.id },
include: {
title: true,
tags: { include: { tag: { select: { name: true } } } },
},
});
if (!entry) return apiError("Diesen Eintrag gibt es nicht.", 404);
const [progress, next] = await Promise.all([
getProgress(user.id, entry.id),
getNextEpisode(user.id, entry.id),
]);
return {
id: entry.id,
status: entry.status,
rating: entry.rating,
notes: entry.notes,
whereToWatch: entry.whereToWatch,
owned: entry.owned,
ownedSource: entry.ownedSource,
favorite: entry.favorite,
currentRun: entry.currentRun,
tags: entry.tags.map((link) => link.tag.name),
title: {
id: entry.title.id,
title: entry.title.title,
type: entry.title.type,
tmdbId: entry.title.tmdbId,
year: entry.title.releaseDate?.getFullYear() ?? null,
genres: entry.title.genres,
},
progress,
nextEpisode: next,
};
});
const patchSchema = z.object({
status: z.enum(STATUSES).optional(),
rating: z.number().int().min(1).max(10).nullable().optional(),
notes: z.string().max(5000).nullable().optional(),
whereToWatch: z.string().max(120).nullable().optional(),
favorite: z.boolean().optional(),
owned: z.boolean().optional(),
ownedSource: z.string().max(80).nullable().optional(),
});
/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */
export const PATCH = withApi(async (request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const { status, ...details } = await readJson(request, patchSchema);
if (status) await setStatus(user.id, entryId, status);
if (Object.keys(details).length > 0) {
await updateEntryDetails(user.id, entryId, details);
}
return { ok: true };
});
/** DELETE /api/v1/bibliothek/{id} — in den Papierkorb legen. */
export const DELETE = withApi(async (_request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
await moveToTrash(user.id, entryId);
return { ok: true, message: "In den Papierkorb gelegt." };
});
@@ -0,0 +1,104 @@
import { z } from "zod";
import { apiError, readJson, withApi } from "@/lib/api";
import { prisma } from "@/lib/db";
import {
markEpisodesWatched,
markMovieWatched,
markSeasonWatched,
markUpTo,
undoWatches,
unmarkEpisodes,
unmarkMovie,
watchNextEpisode,
} from "@/lib/library";
/** GET — die Sichtungen eines Eintrags, jüngste zuerst. */
export const GET = withApi(async (_request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const watches = await prisma.watch.findMany({
where: { userId: user.id, entryId },
orderBy: { watchedAt: "desc" },
take: 500,
include: { episode: { select: { seasonNumber: true, episodeNumber: true, name: true } } },
});
return {
total: watches.length,
items: watches.map((watch) => ({
id: watch.id,
run: watch.run,
watchedAt: watch.watchedAt,
minutes: watch.minutes,
episode: watch.episode,
})),
};
});
const postSchema = z.union([
z.object({ action: z.literal("next") }),
z.object({ action: z.literal("movie") }),
z.object({
action: z.literal("episodes"),
episodeIds: z.array(z.string().min(1)).min(1).max(500),
}),
z.object({ action: z.literal("season"), seasonId: z.string().min(1) }),
z.object({ action: z.literal("upTo"), episodeId: z.string().min(1) }),
]);
/**
* POST — etwas als gesehen markieren.
*
* Die Antwort enthält immer `watchIds`. Damit lässt sich die Aktion über
* DELETE gezielt zurücknehmen, ohne raten zu müssen, was dazugehörte.
*/
export const POST = withApi(async (request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const body = await readJson(request, postSchema);
switch (body.action) {
case "next":
return watchNextEpisode(user.id, entryId);
case "movie":
return markMovieWatched(user.id, entryId);
case "episodes":
return markEpisodesWatched(user.id, entryId, body.episodeIds);
case "season":
return markSeasonWatched(user.id, entryId, body.seasonId);
case "upTo":
return markUpTo(user.id, entryId, body.episodeId);
}
});
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.
*
* 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);
const body = await readJson(request, deleteSchema);
if ("watchIds" in body) {
await undoWatches(user.id, entryId, body.watchIds);
} else if ("episodeIds" in body) {
await unmarkEpisodes(user.id, entryId, body.episodeIds);
} else {
await unmarkMovie(user.id, entryId);
}
return { ok: true };
});
+64
View File
@@ -0,0 +1,64 @@
import { z } from "zod";
import { readJson, readPaging, withApi } from "@/lib/api";
import { getLibrary } from "@/lib/queries";
import { addToLibrary } from "@/lib/library";
import { ensureTitle } from "@/lib/catalog";
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const;
/** GET /api/v1/bibliothek — Bibliothek lesen, mit denselben Filtern wie im UI. */
export const GET = withApi(async (request, user) => {
const url = new URL(request.url);
const { limit, offset } = readPaging(request);
const status = url.searchParams.get("status");
const type = url.searchParams.get("typ");
const items = await getLibrary(user.id, {
query: url.searchParams.get("q") ?? undefined,
status: STATUSES.includes(status as EntryStatus) ? (status as EntryStatus) : undefined,
type: type === "SERIES" || type === "MOVIE" ? (type as TitleType) : undefined,
genre: url.searchParams.get("genre") ?? undefined,
tag: url.searchParams.get("tag") ?? undefined,
provider: url.searchParams.get("dienst") ?? undefined,
owned: url.searchParams.get("sammlung") === "1" ? true : undefined,
});
return {
total: items.length,
limit,
offset,
items: items.slice(offset, offset + limit),
};
});
const addSchema = z
.object({
titleId: z.string().min(1).optional(),
tmdbId: z.number().int().positive().optional(),
type: z.enum(["SERIES", "MOVIE"]).optional(),
status: z.enum(STATUSES).default("WATCHLIST"),
})
.refine((value) => Boolean(value.titleId) || Boolean(value.tmdbId && value.type), {
message: "Entweder titleId oder tmdbId zusammen mit type angeben.",
});
/** POST /api/v1/bibliothek — Titel aufnehmen, per interner ID oder TMDB-ID. */
export const POST = withApi(async (request, user) => {
const body = await readJson(request, addSchema);
let titleId = body.titleId;
if (!titleId && body.tmdbId && body.type) {
const title = await ensureTitle(body.type, body.tmdbId, { region: user.region });
titleId = title.id;
}
if (!titleId) {
throw new z.ZodError([
{ code: "custom", path: ["titleId"], message: "Kein Titel angegeben." },
]);
}
const entry = await addToLibrary(user.id, titleId, body.status);
return { id: entry.id, titleId: entry.titleId, status: entry.status };
});
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { withApi } from "@/lib/api";
import { exportCsv, exportJson } from "@/lib/transfer";
/**
* GET /api/v1/export?format=json|csv
*
* Vollständiger Export der eigenen Daten. JSON enthält zusätzlich Sichtungen
* und Universen, CSV ist die schlanke Tabellenform für Tabellenkalkulationen.
*/
export const GET = withApi(async (request, user) => {
const url = new URL(request.url);
const format = url.searchParams.get("format") === "csv" ? "csv" : "json";
const stamp = new Date().toISOString().slice(0, 10);
if (format === "csv") {
const csv = await exportCsv(user.id);
return new NextResponse(csv, {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="flimmer-${stamp}.csv"`,
},
});
}
const data = await exportJson(user.id);
return new NextResponse(JSON.stringify(data, null, 2), {
headers: {
"content-type": "application/json; charset=utf-8",
"content-disposition": `attachment; filename="flimmer-${stamp}.json"`,
},
});
});
+29
View File
@@ -0,0 +1,29 @@
import { withApi } from "@/lib/api";
import { getUpcomingEpisodes } from "@/lib/stats";
/**
* GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30
*
* Kommende Episoden der getrackten Serien. Ohne Angabe: die nächsten 30 Tage.
*/
export const GET = withApi(async (request, user) => {
const url = new URL(request.url);
const parse = (value: string | null, fallback: Date) => {
if (!value) return fallback;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? fallback : date;
};
const from = parse(url.searchParams.get("von"), new Date());
const to = parse(url.searchParams.get("bis"), new Date(Date.now() + 30 * 86_400_000));
const episodes = await getUpcomingEpisodes(user.id, from, to);
return {
from: from.toISOString(),
to: to.toISOString(),
total: episodes.length,
items: episodes,
};
});
+19
View File
@@ -0,0 +1,19 @@
import { withApi } from "@/lib/api";
import { prisma } from "@/lib/db";
/** GET /api/v1/me — wer bin ich, und wie groß ist meine Bibliothek? */
export const GET = withApi(async (_request, user) => {
const [entries, watches] = await Promise.all([
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
prisma.watch.count({ where: { userId: user.id } }),
]);
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
region: user.region,
library: { entries, watches },
};
});
+365
View File
@@ -0,0 +1,365 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import pkg from "../../../../../package.json";
/**
* Maschinenlesbare Beschreibung der REST-API.
*
* Handgeschrieben statt aus Zod erzeugt: die Schnittstelle ist überschaubar,
* und ein Generator brächte eine weitere Abhängigkeit samt Versionsrhythmus
* für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
*/
export const dynamic = "force-dynamic";
export function GET() {
const document = {
openapi: "3.1.0",
info: {
title: "Flimmer API",
version: pkg.version,
description:
"REST-Schnittstelle einer Flimmer-Instanz. Anmeldung über einen persönlichen " +
"API-Schlüssel (Einstellungen → API-Schlüssel) als Bearer-Token. Alle Antworten " +
"sind JSON; Zeitangaben in ISO 8601, Laufzeiten in Minuten.",
license: { name: "MIT" },
},
servers: [{ url: `${env.appUrl}/api/v1` }],
security: [{ bearerAuth: [] }],
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer", description: "Persönlicher API-Schlüssel" },
},
schemas: {
Fehler: {
type: "object",
properties: { error: { type: "string" }, details: {} },
required: ["error"],
},
Sichtungsergebnis: {
type: "object",
properties: {
watchIds: {
type: "array",
items: { type: "string" },
description: "Kennungen der neu angelegten Sichtungen — für DELETE zum Zurücknehmen.",
},
status: { type: "string" },
label: { type: "string" },
},
},
},
},
paths: {
"/me": {
get: {
summary: "Angemeldetes Konto",
responses: { "200": { description: "Konto samt Umfang der Bibliothek" } },
},
},
"/bibliothek": {
get: {
summary: "Bibliothek lesen",
parameters: [
{ name: "q", in: "query", schema: { type: "string" }, description: "Volltextsuche" },
{
name: "status",
in: "query",
schema: {
type: "string",
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
},
},
{ name: "typ", in: "query", schema: { type: "string", enum: ["SERIES", "MOVIE"] } },
{ name: "genre", in: "query", schema: { type: "string" } },
{ name: "tag", in: "query", schema: { type: "string" } },
{ name: "dienst", in: "query", schema: { type: "string" } },
{
name: "sammlung",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "Nur Titel, die in der eigenen Sammlung liegen.",
},
{ name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } },
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } },
],
responses: { "200": { description: "Einträge mit Fortschritt" } },
},
post: {
summary: "Titel aufnehmen",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
titleId: { type: "string", description: "Interne Kennung" },
tmdbId: { type: "integer", description: "Alternativ: TMDB-Kennung" },
type: { type: "string", enum: ["SERIES", "MOVIE"] },
status: {
type: "string",
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
default: "WATCHLIST",
},
},
},
},
},
},
responses: { "200": { description: "Angelegter oder vorhandener Eintrag" } },
},
},
"/bibliothek/{entryId}": {
parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }],
get: { summary: "Eintrag mit Fortschritt", responses: { "200": { description: "Eintrag" } } },
patch: {
summary: "Status, Bewertung, Notiz ändern",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "string",
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
},
rating: { type: "integer", minimum: 1, maximum: 10, nullable: true },
notes: { type: "string", nullable: true },
whereToWatch: { type: "string", nullable: true },
favorite: { type: "boolean" },
owned: {
type: "boolean",
description: "Liegt der Titel in der eigenen Sammlung?",
},
ownedSource: {
type: "string",
nullable: true,
description: "Wo genau — Jellyfin, Plex, Blu-ray …",
},
},
},
},
},
},
responses: { "200": { description: "Gespeichert" } },
},
delete: {
summary: "In den Papierkorb legen",
responses: { "200": { description: "Verschoben" } },
},
},
"/bibliothek/{entryId}/sichtungen": {
parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }],
get: { summary: "Sichtungen auflisten", responses: { "200": { description: "Sichtungen" } } },
post: {
summary: "Als gesehen markieren",
description:
"action=next hakt die nächste offene Episode ab, movie einen Film, " +
"episodes eine Auswahl, season eine ganze Staffel, upTo alles bis zur genannten Episode.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
oneOf: [
{
type: "object",
properties: { action: { const: "next" } },
required: ["action"],
},
{
type: "object",
properties: { action: { const: "movie" } },
required: ["action"],
},
{
type: "object",
properties: {
action: { const: "episodes" },
episodeIds: { type: "array", items: { type: "string" } },
},
required: ["action", "episodeIds"],
},
{
type: "object",
properties: { action: { const: "season" }, seasonId: { type: "string" } },
required: ["action", "seasonId"],
},
{
type: "object",
properties: { action: { const: "upTo" }, episodeId: { type: "string" } },
required: ["action", "episodeId"],
},
],
},
},
},
},
responses: {
"200": {
description: "Angelegte Sichtungen",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Sichtungsergebnis" },
},
},
},
},
},
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: {
"application/json": {
schema: {
oneOf: [
{
type: "object",
properties: { watchIds: { type: "array", items: { type: "string" } } },
required: ["watchIds"],
},
{
type: "object",
properties: { episodeIds: { type: "array", items: { type: "string" } } },
required: ["episodeIds"],
},
{
type: "object",
properties: { action: { const: "movie" } },
required: ["action"],
},
],
},
},
},
},
responses: { "200": { description: "Zurückgenommen" } },
},
},
"/suche": {
get: {
summary: "TMDB durchsuchen",
parameters: [
{ name: "q", in: "query", required: true, schema: { type: "string", minLength: 2 } },
{ name: "typ", in: "query", schema: { type: "string", enum: ["serie", "film"] } },
],
responses: {
"200": { description: "Treffer mit TMDB-Kennung" },
"503": { description: "Kein TMDB-Schlüssel hinterlegt" },
},
},
},
"/kalender": {
get: {
summary: "Kommende Episoden",
parameters: [
{ name: "von", in: "query", schema: { type: "string", format: "date" } },
{ name: "bis", in: "query", schema: { type: "string", format: "date" } },
],
responses: { "200": { description: "Termine im Zeitraum" } },
},
},
"/statistik": {
get: {
summary: "Kennzahlen und Verteilungen",
parameters: [{ name: "jahr", in: "query", schema: { type: "integer" } }],
responses: { "200": { description: "Statistik" } },
},
},
"/universen": {
get: { summary: "Universen auflisten", responses: { "200": { description: "Universen" } } },
post: {
summary: "Universum anlegen",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: { name: { type: "string" }, description: { type: "string" } },
required: ["name"],
},
},
},
},
responses: { "200": { description: "Angelegt" } },
},
},
"/universen/{id}": {
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" } },
},
post: {
summary: "Titel ins Universum aufnehmen",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: { titleId: { type: "string" } },
required: ["titleId"],
},
},
},
},
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: {
summary: "Eigene Daten exportieren",
parameters: [
{ name: "format", in: "query", schema: { type: "string", enum: ["json", "csv"] } },
],
responses: { "200": { description: "Datei" } },
},
},
},
};
return NextResponse.json(document, {
headers: { "cache-control": "public, max-age=300" },
});
}
+25
View File
@@ -0,0 +1,25 @@
import { withApi } from "@/lib/api";
import {
getGenreDistribution,
getMonthlyActivity,
getProviderDistribution,
getTotals,
getYearReview,
} from "@/lib/stats";
/** GET /api/v1/statistik?jahr=2026 — alle Kennzahlen auf einmal. */
export const GET = withApi(async (request, user) => {
const url = new URL(request.url);
const parsed = Number.parseInt(url.searchParams.get("jahr") ?? "", 10);
const year = Number.isFinite(parsed) ? parsed : new Date().getFullYear();
const [totals, monthly, providers, genres, review] = await Promise.all([
getTotals(user.id),
getMonthlyActivity(user.id, 12),
getProviderDistribution(user.id),
getGenreDistribution(user.id),
getYearReview(user.id, year),
]);
return { totals, monthly, providers, genres, yearReview: review };
});
+53
View File
@@ -0,0 +1,53 @@
import { apiError, withApi } from "@/lib/api";
import { env } from "@/lib/env";
import { searchMovies, searchMulti, searchSeries, TmdbNotConfiguredError } from "@/lib/tmdb";
/**
* GET /api/v1/suche?q=…&typ=serie|film
*
* Durchsucht TMDB. Die Treffer tragen die TMDB-ID — damit lässt sich der
* Titel über POST /api/v1/bibliothek direkt aufnehmen.
*/
export const GET = withApi(async (request) => {
const url = new URL(request.url);
const query = (url.searchParams.get("q") ?? "").trim();
const type = url.searchParams.get("typ");
if (query.length < 2) {
return apiError("Der Suchbegriff braucht mindestens zwei Zeichen.", 400);
}
if (!env.tmdb.isConfigured) {
return apiError("Auf dieser Instanz ist kein TMDB-Schlüssel hinterlegt.", 503);
}
try {
const data =
type === "serie"
? await searchSeries(query)
: type === "film"
? await searchMovies(query)
: await searchMulti(query);
return {
query,
total: data.total_results,
items: data.results.map((item) => {
const isSeries =
item.media_type === "tv" || (type === "serie" && item.media_type === undefined);
const date = isSeries ? item.first_air_date : item.release_date;
return {
tmdbId: item.id,
type: isSeries ? "SERIES" : "MOVIE",
title: item.name ?? item.title ?? "Ohne Titel",
overview: item.overview ?? null,
posterPath: item.poster_path ?? null,
year: date ? Number.parseInt(date.slice(0, 4), 10) : null,
voteAverage: item.vote_average ?? null,
};
}),
};
} catch (error) {
if (error instanceof TmdbNotConfiguredError) return apiError(error.message, 503);
return apiError(error instanceof Error ? error.message : "Die Suche ist fehlgeschlagen.", 502);
}
});
+50
View File
@@ -0,0 +1,50 @@
import { z } from "zod";
import { apiError, readJson, withApi } from "@/lib/api";
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) => {
const { id } = await context.params;
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
const url = new URL(request.url);
const universe = await getUniverse(user.id, id, url.searchParams.get("reihenfolge") ?? undefined);
if (!universe) return apiError("Dieses Universum gibt es nicht.", 404);
return universe;
});
const addSchema = z.object({ titleId: z.string().min(1) });
/** POST /api/v1/universen/{id} — Titel aufnehmen. */
export const POST = 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, addSchema);
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 };
});
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod";
import { readJson, withApi } from "@/lib/api";
import { createUniverse, listUniverses } from "@/lib/universes";
/** GET /api/v1/universen — eigene und geteilte Universen. */
export const GET = withApi(async (_request, user) => {
const universes = await listUniverses(user.id);
return { total: universes.length, items: universes };
});
const createSchema = z.object({
name: z.string().trim().min(1).max(120),
description: z.string().max(2000).optional(),
});
/** POST /api/v1/universen — neues Universum anlegen. */
export const POST = withApi(async (request, user) => {
const body = await readJson(request, createSchema);
const universe = await createUniverse(user.id, body.name, body.description);
return { id: universe.id, name: universe.name, slug: universe.slug };
});
+6
View File
@@ -6,6 +6,7 @@ import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type Libr
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { LibraryCard } from "@/components/library/LibraryCard"; import { LibraryCard } from "@/components/library/LibraryCard";
import { LibraryFilters } from "@/components/library/LibraryFilters"; import { LibraryFilters } from "@/components/library/LibraryFilters";
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
import type { EntryStatus, TitleType } from "@/generated/prisma/client"; import type { EntryStatus, TitleType } from "@/generated/prisma/client";
export const metadata: Metadata = { title: "Bibliothek" }; export const metadata: Metadata = { title: "Bibliothek" };
@@ -29,6 +30,7 @@ export default async function LibraryPage({
genre: params.genre || undefined, genre: params.genre || undefined,
tag: params.tag || undefined, tag: params.tag || undefined,
provider: params.dienst || undefined, provider: params.dienst || undefined,
owned: params.sammlung === "1" ? true : undefined,
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined, sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
}; };
@@ -55,8 +57,10 @@ export default async function LibraryPage({
</Link> </Link>
</header> </header>
<SearchPendingProvider>
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />} {facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
<SearchResults>
<div className="mt-6"> <div className="mt-6">
{items.length === 0 ? ( {items.length === 0 ? (
<div className="fl-card p-8 text-center"> <div className="fl-card p-8 text-center">
@@ -88,6 +92,8 @@ export default async function LibraryPage({
</> </>
)} )}
</div> </div>
</SearchResults>
</SearchPendingProvider>
</AppShell> </AppShell>
); );
} }
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { env } from "@/lib/env";
import { ApiKeys } from "@/components/settings/Forms";
export const metadata: Metadata = { title: "API-Schlüssel" };
export default async function ApiPage() {
const user = await requireUser("/einstellungen/api");
const keys = await prisma.apiKey.findMany({
where: { userId: user.id, revokedAt: null },
orderBy: { createdAt: "desc" },
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true },
});
const example = [
'curl -H "Authorization: Bearer <dein-schlüssel>" \\',
` ${env.appUrl}/api/v1/bibliothek?status=WATCHING`,
].join("\n");
return (
<div className="space-y-6">
<section className="fl-card p-5">
<h2 className="text-lg font-semibold">API-Schlüssel</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
Für Skripte und eigene Anwendungen. Ein Schlüssel hat dieselben Rechte wie dein Konto —
gib ihn nicht weiter und zieh ihn zurück, sobald du ihn nicht mehr brauchst.
</p>
<ApiKeys keys={keys} />
</section>
<section className="fl-card p-5">
<h2 className="mb-3 text-lg font-semibold">So wird er benutzt</h2>
<pre className="overflow-x-auto rounded-xl bg-[var(--fl-surface-2)] p-3 text-xs leading-relaxed">
{example}
</pre>
<p className="mt-3 text-sm text-[var(--fl-muted)]">
Die vollständige Beschreibung steht in{" "}
<a
href="/api/v1/openapi.json"
className="text-[var(--fl-accent)] underline-offset-4 hover:underline"
>
openapi.json
</a>{" "}
und in <code>docs/API.md</code> im Repository.
</p>
</section>
</div>
);
}
@@ -0,0 +1,63 @@
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { env } from "@/lib/env";
import { ChannelList } from "@/components/settings/Forms";
export const metadata: Metadata = { title: "Benachrichtigungen" };
export default async function NotificationsPage() {
const user = await requireUser("/einstellungen/benachrichtigungen");
const [channels, recent] = await Promise.all([
prisma.notificationChannel.findMany({
where: { userId: user.id },
orderBy: { createdAt: "asc" },
}),
prisma.notificationLog.findMany({
where: { userId: user.id },
orderBy: { sentAt: "desc" },
take: 10,
}),
]);
return (
<div className="space-y-6">
<section className="fl-card p-5">
<h2 className="text-lg font-semibold">Kanäle</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
Wohin Flimmer dir Bescheid gibt. Was gemeldet wird, stellst du im Profil ein.
</p>
<ChannelList
channels={channels.map((channel) => ({
id: channel.id,
kind: channel.kind,
name: channel.name,
config: (channel.config ?? {}) as Record<string, string>,
enabled: channel.enabled,
}))}
smtpReady={env.smtp.isConfigured}
/>
</section>
<section className="fl-card p-5">
<h2 className="mb-4 text-lg font-semibold">Zuletzt verschickt</h2>
{recent.length === 0 ? (
<p className="text-sm text-[var(--fl-faint)]">Noch nichts verschickt.</p>
) : (
<ul className="divide-y divide-[var(--fl-border)] text-sm">
{recent.map((entry) => (
<li key={entry.id} className="py-2.5">
<p className="font-medium">{entry.subject}</p>
<p className="text-xs text-[var(--fl-faint)]">
{entry.sentAt.toLocaleString("de-DE")}
{entry.ok ? "" : ` · fehlgeschlagen: ${entry.error ?? "unbekannt"}`}
</p>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { ImportForm } from "@/components/settings/Forms";
export const metadata: Metadata = { title: "Import und Export" };
export default async function DataPage() {
const user = await requireUser("/einstellungen/daten");
const [entries, watches] = await Promise.all([
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
prisma.watch.count({ where: { userId: user.id } }),
]);
return (
<div className="space-y-6">
<section className="fl-card p-5">
<h2 className="text-lg font-semibold">Import</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
Bringt Titel aus einem anderen Werkzeug mit.
</p>
<ImportForm />
</section>
<section className="fl-card p-5">
<h2 className="text-lg font-semibold">Export</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
Deine Daten gehören dir: {entries} Einträge und {watches} Sichtungen zum Mitnehmen.
</p>
<div className="flex flex-wrap gap-2">
<a href="/api/v1/export?format=json" className="fl-btn fl-btn-primary" download>
Als JSON
</a>
<a href="/api/v1/export?format=csv" className="fl-btn fl-btn-ghost" download>
Als CSV
</a>
</div>
<p className="mt-3 text-xs text-[var(--fl-faint)]">
JSON enthält zusätzlich alle Sichtungen und Universen, CSV ist die schlanke Tabellenform
für Tabellenkalkulationen.
</p>
</section>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { AppShell } from "@/components/AppShell";
import { SettingsNav } from "@/components/settings/SettingsNav";
/** Rahmen der Einstellungen: Seitennavigation links, Inhalt rechts. */
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser("/einstellungen");
const settings = await getSettings();
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
</header>
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)]">
<SettingsNav isAdmin={user.role === "ADMIN"} />
<div className="min-w-0">{children}</div>
</div>
</AppShell>
);
}
+110
View File
@@ -0,0 +1,110 @@
import type { Metadata } from "next";
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" };
export default async function SettingsPage() {
const session = await requireUser("/einstellungen");
const settings = await getSettings();
const user = await prisma.user.findUniqueOrThrow({
where: { id: session.id },
select: {
name: true,
email: true,
region: true,
publicProfile: true,
profileSlug: true,
showAchievements: true,
notifyNewEpisode: true,
notifyRelease: true,
notifyAvailability: true,
passwordHash: true,
},
});
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">
<h2 className="mb-4 text-lg font-semibold">Profil</h2>
<ProfileForm user={profile} publicProfilesEnabled={settings.publicProfilesEnabled} />
</section>
{passwordHash && (
<section className="fl-card p-5">
<h2 className="mb-4 text-lg font-semibold">Passwort</h2>
<PasswordForm />
</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>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { getUpcomingEpisodes } from "@/lib/stats";
import { AppShell } from "@/components/AppShell";
import { CalendarMonth } from "@/components/CalendarMonth";
import { episodeCode, formatDateLong } from "@/lib/labels";
export const metadata: Metadata = { title: "Kalender" };
/** Monatsangabe aus der URL („2026-09") oder der laufende Monat. */
function parseMonth(value: string | undefined): Date {
const match = value?.match(/^(\d{4})-(\d{2})$/);
if (match) {
const year = Number(match[1]);
const month = Number(match[2]) - 1;
if (year >= 1900 && year <= 2200 && month >= 0 && month <= 11) {
return new Date(year, month, 1);
}
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
}
function monthParam(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
}
export default async function CalendarPage({
searchParams,
}: {
searchParams: Promise<{ monat?: string }>;
}) {
const user = await requireUser("/kalender");
const settings = await getSettings();
const params = await searchParams;
const month = parseMonth(params.monat);
const from = new Date(month.getFullYear(), month.getMonth(), 1);
// Das Raster reicht in den Vor- und Folgemonat hinein — die Termine auch.
const gridFrom = new Date(from.getTime() - 7 * 86_400_000);
const gridTo = new Date(from.getFullYear(), from.getMonth() + 1, 7);
const episodes = await getUpcomingEpisodes(user.id, gridFrom, gridTo);
const inMonth = episodes.filter((episode) => episode.airDate.getMonth() === month.getMonth());
const previous = new Date(month.getFullYear(), month.getMonth() - 1, 1);
const next = new Date(month.getFullYear(), month.getMonth() + 1, 1);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Kalender</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Ausstrahlungstermine der Serien in deiner Bibliothek.
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Monat wechseln">
<Link
href={`/kalender?monat=${monthParam(previous)}`}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
aria-label={`Zum ${previous.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
>
←
</Link>
<span className="min-w-40 text-center font-medium">
{month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
</span>
<Link
href={`/kalender?monat=${monthParam(next)}`}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
aria-label={`Zum ${next.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
>
→
</Link>
<Link href="/kalender" className="ml-1 text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Heute
</Link>
</nav>
</header>
<div className="fl-card overflow-x-auto p-3 sm:p-4">
<div className="min-w-[44rem]">
<CalendarMonth month={month} episodes={episodes} />
</div>
</div>
<section className="mt-6" aria-labelledby="liste">
<h2 id="liste" className="mb-3 text-lg font-semibold">
Termine im {month.toLocaleDateString("de-DE", { month: "long" })}
</h2>
{inMonth.length === 0 ? (
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
In diesem Monat läuft nichts aus deiner Bibliothek.
</div>
) : (
<ol className="fl-card divide-y divide-[var(--fl-border)]">
{inMonth.map((episode) => (
<li key={episode.episodeId} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
<span className="w-52 shrink-0 text-sm text-[var(--fl-faint)]">
{formatDateLong(episode.airDate)}
</span>
<Link
href={`/titel/${episode.titleId}`}
className="min-w-0 flex-1 text-sm hover:text-[var(--fl-accent)]"
>
<span className="font-medium">{episode.title}</span>
<span className="ml-2 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>
<span className="ml-2 text-[var(--fl-muted)]">{episode.name}</span>
</Link>
{episode.runtime && (
<span className="shrink-0 text-xs text-[var(--fl-faint)]">{episode.runtime} min</span>
)}
</li>
))}
</ol>
)}
</section>
</AppShell>
);
}
+18 -5
View File
@@ -1,18 +1,30 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Outfit } from "next/font/google"; import localFont from "next/font/local";
import { BrandDefs } from "@/components/brand/FlimmerLogo"; import { BrandDefs } from "@/components/brand/FlimmerLogo";
import { ToastProvider } from "@/components/Toasts"; import { ToastProvider } from "@/components/Toasts";
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import "./globals.css"; import "./globals.css";
/** /**
* Outfit wird beim Bauen heruntergeladen und mit ausgeliefert — die laufende * Outfit liegt als Datei im Repository.
* Instanz fragt also niemals bei Google an. *
* next/font/google würde die Schrift beim Bauen herunterladen — das macht den
* Build von einer Netzverbindung abhängig und ließ ihn im Container hängen.
* Für eine selbst gehostete Anwendung ist die mitgelieferte Datei ohnehin das
* Richtige: kein Abruf beim Bauen, keiner zur Laufzeit.
*
* Es ist die variable Fassung (Strichstärke 100–900), aufgeteilt in Latin und
* Latin-Extended — der Browser lädt nur, was die Seite wirklich braucht.
*/ */
const outfit = Outfit({ const outfit = localFont({
subsets: ["latin"], src: [
{ path: "../../public/fonts/outfit-latin.woff2", weight: "100 900", style: "normal" },
{ path: "../../public/fonts/outfit-latin-ext.woff2", weight: "100 900", style: "normal" },
],
display: "swap", display: "swap",
variable: "--font-outfit", variable: "--font-outfit",
fallback: ["Inter", "Segoe UI", "system-ui", "sans-serif"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -75,6 +87,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
Zum Inhalt springen Zum Inhalt springen
</a> </a>
<ToastProvider>{children}</ToastProvider> <ToastProvider>{children}</ToastProvider>
<ServiceWorkerRegistration />
</body> </body>
</html> </html>
); );
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
import type { Metadata } from "next";
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
export const metadata: Metadata = { title: "Keine Verbindung" };
/**
* Rückfallseite des Service Workers.
*
* Bewusst ohne Datenzugriff: sie muss auch dann funktionieren, wenn der
* Server nicht erreichbar ist, und wird deshalb statisch vorgerendert.
*/
export default function OfflinePage() {
return (
<main
id="inhalt"
className="flex min-h-dvh flex-col items-center justify-center px-6 text-center"
>
<FlimmerMark className="h-16 w-16" />
<h1 className="mt-6 text-2xl font-semibold tracking-tight">Gerade keine Verbindung</h1>
<p className="mt-2 max-w-sm text-[var(--fl-muted)]">
Flimmer erreicht deinen Server nicht. Bereits geladene Seiten und Poster bleiben verfügbar —
alles Weitere kommt zurück, sobald die Verbindung wieder steht.
</p>
<Link href="/" className="fl-btn fl-btn-primary mt-6">
Noch einmal versuchen
</Link>
</main>
);
}
+190 -44
View File
@@ -2,21 +2,196 @@ import Link from "next/link";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import {
getContinueWatching,
getNewlyAvailable,
getUpcomingEpisodes,
} from "@/lib/stats";
import { prisma } from "@/lib/db";
import { episodeCode, formatDate, formatRelative, isUpcoming } from "@/lib/labels";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { FlimmerMark } from "@/components/brand/FlimmerLogo"; import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import { QuickWatch } from "@/components/library/QuickWatch";
/** /**
* Übersichtsseite. * Übersicht: der Einstieg für „was schaue ich als Nächstes".
* *
* In dieser Etappe steht hier die Begrüßung und der Hinweis auf die nächsten * Drei Fragen, drei Bereiche — weiterschauen, was demnächst kommt, was auf der
* Schritte; ab Etappe 5 ziehen „Weiterschauen", Kalender und Neuzugänge ein. * Watchlist verfügbar geworden ist.
*/ */
export default async function DashboardPage() { export default async function DashboardPage() {
const user = await requireUser(); const user = await requireUser();
const settings = await getSettings(); const settings = await getSettings();
const now = new Date();
const inFourWeeks = new Date(now.getTime() + 28 * 86_400_000);
const [continueItems, upcoming, available, libraryCount] = await Promise.all([
getContinueWatching(user.id, 8),
getUpcomingEpisodes(user.id, now, inFourWeeks),
getNewlyAvailable(user.id, user.region),
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
]);
if (libraryCount === 0) {
return ( return (
<AppShell user={user} instanceName={settings.instanceName}> <AppShell user={user} instanceName={settings.instanceName}>
<EmptyStart tmdbReady={env.tmdb.isConfigured} name={user.name} />
</AppShell>
);
}
return (
<AppShell user={user} instanceName={settings.instanceName}>
<h1 className="sr-only">Übersicht</h1>
<section aria-labelledby="weiterschauen">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 id="weiterschauen" className="text-lg font-semibold">
Weiterschauen
</h2>
<Link href="/bibliothek?status=WATCHING" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Alle laufenden
</Link>
</div>
{continueItems.length === 0 ? (
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
Gerade läuft nichts. Setz eine Serie auf „Schaue gerade", dann steht sie hier.
</div>
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{continueItems.map((item) => (
<li key={item.entryId} className="fl-card flex gap-3 p-3">
<Link href={`/titel/${item.titleId}`} className="w-16 shrink-0 sm:w-20">
<Poster path={item.posterPath} alt={item.title} size="w185" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<Link href={`/titel/${item.titleId}`} className="truncate font-medium hover:text-[var(--fl-accent)]">
{item.title}
</Link>
{item.next && (
<p className="mt-0.5 truncate text-sm">
<span className="fl-gradient-text font-mono font-semibold">
{episodeCode(item.next.seasonNumber, item.next.episodeNumber)}
</span>{" "}
<span className="text-[var(--fl-muted)]">{item.next.name}</span>
</p>
)}
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{item.watched} von {item.total} Episoden
{item.lastWatchedAt ? ` · zuletzt ${formatRelative(item.lastWatchedAt)}` : ""}
</p>
<div className="mt-2">
<ProgressBar
percent={item.percent}
label={`${item.watched} von ${item.total} Episoden gesehen`}
/>
</div>
<div className="mt-2.5">
{item.next && isUpcoming(item.next.airDate) ? (
<p className="text-xs text-[var(--fl-faint)]">
Nächste Episode erst am {formatDate(item.next.airDate)}
</p>
) : (
<QuickWatch entryId={item.entryId} />
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<div className="mt-8 grid gap-6 lg:grid-cols-2">
<section aria-labelledby="demnaechst">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 id="demnaechst" className="text-lg font-semibold">
Demnächst
</h2>
<Link href="/kalender" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Zum Kalender
</Link>
</div>
<div className="fl-card p-4">
{upcoming.length === 0 ? (
<p className="text-sm text-[var(--fl-muted)]">
In den nächsten vier Wochen ist keine Episode deiner Serien angekündigt.
</p>
) : (
<ol className="space-y-2.5">
{upcoming.slice(0, 6).map((episode) => (
<li key={episode.episodeId} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs text-[var(--fl-faint)]">
{formatDate(episode.airDate)}
</span>
<Link
href={`/titel/${episode.titleId}`}
className="min-w-0 flex-1 truncate text-sm hover:text-[var(--fl-accent)]"
>
<span className="font-medium">{episode.title}</span>
<span className="ml-1.5 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>
</Link>
</li>
))}
</ol>
)}
</div>
</section>
<section aria-labelledby="verfuegbar">
<h2 id="verfuegbar" className="mb-3 text-lg font-semibold">
Neu verfügbar
</h2>
<div className="fl-card p-4">
{available.length === 0 ? (
<p className="text-sm text-[var(--fl-muted)]">
Auf deiner Watchlist ist zuletzt nichts neu dazugekommen. Sobald ein Titel auf
einem Dienst in {user.region} auftaucht, steht er hier.
</p>
) : (
<ul className="space-y-3">
{available.slice(0, 5).map((item) => (
<li key={item.titleId} className="flex items-center gap-3">
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
<Poster path={item.posterPath} alt={item.title} size="w154" />
</Link>
<div className="min-w-0 flex-1">
<Link
href={`/titel/${item.titleId}`}
className="block truncate text-sm font-medium hover:text-[var(--fl-accent)]"
>
{item.title}
</Link>
<p className="truncate text-xs text-[var(--fl-faint)]">
{item.providers.map((provider) => provider.name).join(", ")} ·{" "}
{formatRelative(item.since)}
</p>
</div>
</li>
))}
</ul>
)}
</div>
</section>
</div>
</AppShell>
);
}
function EmptyStart({ name, tmdbReady }: { name: string; tmdbReady: boolean }) {
return (
<section className="fl-card relative overflow-hidden p-6 sm:p-10"> <section className="fl-card relative overflow-hidden p-6 sm:p-10">
<div <div
aria-hidden aria-hidden
@@ -24,60 +199,31 @@ export default async function DashboardPage() {
style={{ backgroundImage: "var(--fl-gradient)" }} style={{ backgroundImage: "var(--fl-gradient)" }}
/> />
<div className="relative"> <div className="relative">
<FlimmerMark className="h-12 w-12" /> <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
<h1 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl"> Hallo {name.split(" ")[0]}.
Hallo {user.name.split(" ")[0]}.
</h1> </h1>
<p className="mt-2 max-w-xl text-[var(--fl-muted)]"> <p className="mt-2 max-w-xl text-[var(--fl-muted)]">
Schön, dass du da bist. Sobald du deinen ersten Titel hinzufügst, findest du hier Deine Bibliothek ist noch leer. Such dir eine Serie oder einen Film — danach findest du
deine laufenden Serien, die nächsten Episoden und was auf deiner Watchlist gerade hier, was als Nächstes dran ist, welche Episoden demnächst kommen und was auf deiner
verfügbar geworden ist. Watchlist verfügbar geworden ist.
</p> </p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<Link href="/suche" className="fl-btn fl-btn-primary"> <Link href="/suche" className="fl-btn fl-btn-primary">
Titel suchen und hinzufügen Titel suchen
</Link> </Link>
<Link href="/bibliothek" className="fl-btn fl-btn-ghost"> <Link href="/titel/neu" className="fl-btn fl-btn-ghost">
Zur Bibliothek Titel selbst anlegen
</Link> </Link>
</div> </div>
{!env.tmdb.isConfigured && ( {!tmdbReady && (
<p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]"> <p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine Metadaten —
Metadaten — trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte die Instanz neu.
die Instanz neu.
</p> </p>
)} )}
</div> </div>
</section> </section>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<PlaceholderCard
title="Weiterschauen"
text="Deine laufenden Serien mit der jeweils nächsten Episode."
/>
<PlaceholderCard
title="Demnächst"
text="Kalender der Episoden, die in den nächsten Wochen ausgestrahlt werden."
/>
<PlaceholderCard
title="Neu verfügbar"
text="Watchlist-Titel, die auf einem deiner Dienste aufgetaucht sind."
/>
</div>
</AppShell>
);
}
function PlaceholderCard({ title, text }: { title: string; text: string }) {
return (
<div className="fl-card p-5">
<h2 className="text-sm font-semibold tracking-wide text-[var(--fl-muted)] uppercase">
{title}
</h2>
<p className="mt-2 text-sm text-[var(--fl-faint)]">{text}</p>
</div>
); );
} }
+318
View File
@@ -0,0 +1,318 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import {
getActiveYears,
getGenreDistribution,
getMonthlyActivity,
getMostWatched,
getProviderDistribution,
getTopRated,
getTotals,
getYearReview,
} from "@/lib/stats";
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" };
export default async function StatsPage({
searchParams,
}: {
searchParams: Promise<{ jahr?: string }>;
}) {
const user = await requireUser("/statistik");
const settings = await getSettings();
const params = await searchParams;
const years = await getActiveYears(user.id);
const requested = Number.parseInt(params.jahr ?? "", 10);
const year = years.includes(requested) ? requested : (years[0] ?? new Date().getFullYear());
const [totals, monthly, providers, genres, topRated, mostWatched, review] = await Promise.all([
getTotals(user.id),
getMonthlyActivity(user.id, 12),
getProviderDistribution(user.id),
getGenreDistribution(user.id),
getTopRated(user.id, 5),
getMostWatched(user.id, 5),
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}>
<header className="mb-5">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
</header>
<div className="fl-card p-8 text-center">
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
Noch keine Sichtungen. Sobald du die erste Episode abhakst, entstehen hier
Schauzeit, Verteilungen und der Jahresrückblick.
</p>
<Link href="/bibliothek" className="fl-btn fl-btn-primary mt-4">
Zur Bibliothek
</Link>
</div>
</AppShell>
);
}
const days = totals.minutes / (60 * 24);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Alles gerechnet aus deinen Sichtungen — Rewatches zählen mit.
</p>
</header>
<section className="fl-card mb-6 p-5 sm:p-7" aria-label="Gesamtschauzeit">
<p className="text-sm font-medium text-[var(--fl-muted)]">Gesamte Schauzeit</p>
<p className="fl-gradient-text mt-1 text-5xl font-semibold tabular-nums sm:text-6xl">
{Math.round(totals.minutes / 60).toLocaleString("de-DE")} h
</p>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{formatMinutes(totals.minutes)} · das sind {days.toFixed(1).replace(".", ",")} Tage am Stück
</p>
<dl className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-4">
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
<Stat
label="In der Bibliothek"
value={`${totals.seriesCount} Serien · ${totals.movieCount} Filme`}
/>
</dl>
</section>
<section className="fl-card mb-6 p-5" aria-labelledby="verlauf">
<h2 id="verlauf" className="mb-1 text-lg font-semibold">
Die letzten zwölf Monate
</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Abgehakte Episoden und Filme je Monat.
</p>
<MonthlyChart points={monthly.map(({ label, episodes, movies, minutes }) => ({ label, episodes, movies, minutes }))} />
</section>
<div className="mb-6 grid gap-6 lg:grid-cols-2">
<section className="fl-card p-5" aria-labelledby="dienste">
<h2 id="dienste" className="mb-1 text-lg font-semibold">
Nach Streamingdienst
</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Wo du tatsächlich geschaut hast — aus dem Feld „wo schaue ich das".
</p>
<DistributionBars
rows={providers.slice(0, 8)}
emptyText="Noch keine Sichtung einem Dienst zugeordnet."
useBrandColors
/>
</section>
<section className="fl-card p-5" aria-labelledby="genres">
<h2 id="genres" className="mb-1 text-lg font-semibold">
Nach Genre
</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Titel mit mehreren Genres zählen in jedem mit.
</p>
<DistributionBars rows={genres.slice(0, 8)} emptyText="Noch keine Genres erfasst." />
</section>
</div>
<section className="fl-card mb-6 p-5" aria-labelledby="rueckblick">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 id="rueckblick" className="text-lg font-semibold">
{review.year} in Zahlen
</h2>
{years.length > 1 && (
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Jahr wählen">
{years.slice(0, 6).map((entry) => (
<Link
key={entry}
href={`/statistik?jahr=${entry}`}
aria-current={entry === review.year ? "true" : undefined}
className="rounded-full border px-3 py-1 text-sm font-medium transition-colors"
style={
entry === review.year
? {
backgroundImage: "var(--fl-gradient)",
color: "var(--fl-on-accent)",
borderColor: "transparent",
}
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
}
>
{entry}
</Link>
))}
</div>
)}
</div>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<Stat label="Schauzeit" value={formatMinutes(review.minutes)} />
<Stat label="Episoden" value={review.episodes.toLocaleString("de-DE")} />
<Stat label="Filme" value={review.movies.toLocaleString("de-DE")} />
<Stat label="Neu angefangen" value={review.titlesStarted.toLocaleString("de-DE")} />
<Stat label="Abgeschlossen" value={review.titlesCompleted.toLocaleString("de-DE")} />
</dl>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
{review.busiestMonth && (
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
<span className="text-[var(--fl-faint)]">Stärkster Monat: </span>
<span className="font-medium">{review.busiestMonth.label}</span>
<span className="text-[var(--fl-muted)]">
{" "}
mit {formatMinutes(review.busiestMonth.minutes)}
</span>
</p>
)}
{review.topTitle && (
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
<span className="text-[var(--fl-faint)]">Die meiste Zeit ging an: </span>
<Link href={`/titel/${review.topTitle.titleId}`} className="font-medium hover:text-[var(--fl-accent)]">
{review.topTitle.title}
</Link>
<span className="text-[var(--fl-muted)]"> ({formatMinutes(review.topTitle.minutes)})</span>
</p>
)}
</div>
</section>
<div className="grid gap-6 lg:grid-cols-2">
<TitleList
id="meistzeit"
heading="Wo die meiste Zeit hinging"
items={mostWatched}
renderMeta={(item) => formatMinutes(item.minutes)}
emptyText="Noch nichts gesehen."
/>
<TitleList
id="bestbewertet"
heading="Deine besten Bewertungen"
items={topRated}
renderMeta={(item) => (item.rating ? `${item.rating}/10` : "—")}
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>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
</div>
);
}
function TitleList({
id,
heading,
items,
renderMeta,
emptyText,
}: {
id: string;
heading: string;
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number; type: string }[];
renderMeta: (item: { rating: number | null; minutes: number }) => string;
emptyText: string;
}) {
return (
<section className="fl-card p-5" aria-labelledby={id}>
<h2 id={id} className="mb-4 text-lg font-semibold">
{heading}
</h2>
{items.length === 0 ? (
<p className="text-sm text-[var(--fl-faint)]">{emptyText}</p>
) : (
<ol className="space-y-3">
{items.map((item, index) => (
<li key={item.titleId} className="flex items-center gap-3">
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
{index + 1}
</span>
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
<Poster path={item.posterPath} alt={item.title} size="w154" />
</Link>
<Link
href={`/titel/${item.titleId}`}
className="min-w-0 flex-1 truncate text-sm font-medium hover:text-[var(--fl-accent)]"
>
{item.title}
</Link>
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
{renderMeta(item)}
</span>
</li>
))}
</ol>
)}
</section>
);
}
+8 -3
View File
@@ -7,6 +7,7 @@ import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster"; import { Poster } from "@/components/Poster";
import { SearchField } from "@/components/SearchField"; import { SearchField } from "@/components/SearchField";
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
export const metadata: Metadata = { title: "Suchen" }; export const metadata: Metadata = { title: "Suchen" };
@@ -54,6 +55,7 @@ export default async function SearchPage({
</p> </p>
</header> </header>
<SearchPendingProvider>
<SearchField initialQuery={query} filter={filter} filters={FILTERS} /> <SearchField initialQuery={query} filter={filter} filters={FILTERS} />
<p className="mt-3 text-sm text-[var(--fl-faint)]"> <p className="mt-3 text-sm text-[var(--fl-faint)]">
@@ -63,11 +65,12 @@ export default async function SearchPage({
</Link> </Link>
</p> </p>
<SearchResults>
<div className="mt-8"> <div className="mt-8">
{!env.tmdb.isConfigured ? ( {!env.tmdb.isConfigured ? (
<Notice tone="warning"> <Notice tone="warning">
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "} Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in
<code>.env</code> ein und starte die Instanz neu. der <code>.env</code> ein und starte die Instanz neu.
</Notice> </Notice>
) : query.length < 2 ? ( ) : query.length < 2 ? (
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice> <Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
@@ -75,6 +78,8 @@ export default async function SearchPage({
<Results query={query} filter={filter} /> <Results query={query} filter={filter} />
)} )}
</div> </div>
</SearchResults>
</SearchPendingProvider>
</AppShell> </AppShell>
); );
} }
@@ -119,7 +124,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
> >
<Poster path={item.posterPath} alt={item.title} size="w342" /> <Poster path={item.posterPath} alt={item.title} size="w342" />
<div className="mt-2 space-y-0.5"> <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} {item.title}
</p> </p>
<p className="text-xs text-[var(--fl-faint)]"> <p className="text-xs text-[var(--fl-faint)]">
+45
View File
@@ -120,6 +120,19 @@ export default async function TitlePage({ params }: { params: Params }) {
{year ? ` · ${year}` : ""} {year ? ` · ${year}` : ""}
{title.status ? ` · ${title.status}` : ""} {title.status ? ` · ${title.status}` : ""}
</p> </p>
{entry?.owned && (
<span
className="rounded-full px-2.5 py-0.5 text-xs font-medium text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
title={
entry.ownedSource
? `In deiner Sammlung: ${entry.ownedSource}`
: "In deiner Sammlung"
}
>
Hab ich{entry.ownedSource ? ` · ${entry.ownedSource}` : ""}
</span>
)}
{entry && ( {entry && (
<span <span
className="rounded-full px-2.5 py-0.5 text-xs font-medium" className="rounded-full px-2.5 py-0.5 text-xs font-medium"
@@ -225,6 +238,7 @@ export default async function TitlePage({ params }: { params: Params }) {
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} /> <SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
) : ( ) : (
<div className="fl-card p-4"> <div className="fl-card p-4">
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
<ProviderList groups={providerGroups} region={user.region} /> <ProviderList groups={providerGroups} region={user.region} />
</div> </div>
)} )}
@@ -245,6 +259,8 @@ export default async function TitlePage({ params }: { params: Params }) {
whereToWatch={entry.whereToWatch} whereToWatch={entry.whereToWatch}
tags={entry.tags.map((link) => link.tag.name)} tags={entry.tags.map((link) => link.tag.name)}
suggestion={suggestion} suggestion={suggestion}
owned={entry.owned}
ownedSource={entry.ownedSource}
/> />
</div> </div>
@@ -253,6 +269,7 @@ export default async function TitlePage({ params }: { params: Params }) {
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]"> <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Wo läuft das? Wo läuft das?
</h2> </h2>
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
<ProviderList groups={providerGroups} region={user.region} /> <ProviderList groups={providerGroups} region={user.region} />
</div> </div>
)} )}
@@ -299,6 +316,34 @@ export default async function TitlePage({ params }: { params: Params }) {
); );
} }
/**
* Hinweis auf die eigene Sammlung — steht bewusst **vor** der
* Streaming-Verfügbarkeit: was man hat, ist die verlässlichere Antwort auf
* „wo kann ich das schauen".
*/
function OwnedNotice({ owned, source }: { owned: boolean; source: string | null }) {
if (!owned) return null;
return (
<p
className="mb-3 flex items-center gap-2 rounded-xl border px-3 py-2 text-sm"
style={{
borderColor: "color-mix(in oklab, var(--fl-success) 45%, transparent)",
backgroundColor: "color-mix(in oklab, var(--fl-success) 10%, transparent)",
color: "var(--fl-success)",
}}
>
<svg viewBox="0 0 24 24" className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>
In deiner Sammlung
{source ? <span className="font-medium"> · {source}</span> : null}
</span>
</p>
);
}
function Fact({ label, children }: { label: string; children: React.ReactNode }) { function Fact({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div> <div>
+193
View File
@@ -0,0 +1,193 @@
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";
import { FlimmerLogo } from "@/components/brand/FlimmerLogo";
import { Poster } from "@/components/Poster";
import { ThemeToggle } from "@/components/ThemeToggle";
type Params = Promise<{ slug: string }>;
/**
* Öffentliche Profilseite.
*
* Zeigt Zahlen und zuletzt Gesehenes — **keine** Notizen, keine Watchlist,
* keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat. Die Seite ist
* nur erreichbar, wenn der Nutzer sie freigeschaltet hat *und* der Betreiber
* öffentliche Profile instanzweit erlaubt.
*/
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { slug } = await params;
const user = await prisma.user.findFirst({
where: { profileSlug: slug, publicProfile: true, isActive: true },
select: { name: true },
});
return user ? { title: `${user.name} · Profil` } : { title: "Profil" };
}
export default async function PublicProfilePage({ params }: { params: Params }) {
const { slug } = await params;
const settings = await getSettings();
if (!settings.publicProfilesEnabled) notFound();
const user = await prisma.user.findFirst({
where: { profileSlug: slug, publicProfile: true, isActive: 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),
getTopRated(user.id, 6),
prisma.watch.findMany({
where: { userId: user.id },
orderBy: { watchedAt: "desc" },
take: 8,
distinct: ["titleId"],
include: {
title: { select: { id: true, title: true, posterPath: true, type: true } },
episode: { select: { seasonNumber: true, episodeNumber: true } },
},
}),
]);
return (
<div className="flex min-h-dvh flex-col">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-4 sm:px-6">
<Link href="/" aria-label="Flimmer">
<FlimmerLogo />
</Link>
<ThemeToggle />
</div>
<main id="inhalt" className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
<header className="fl-card relative overflow-hidden p-6 sm:p-8">
<div
aria-hidden
className="pointer-events-none absolute -right-20 -top-28 h-64 w-64 rounded-full opacity-25 blur-3xl"
style={{ backgroundImage: "var(--fl-gradient)" }}
/>
<div className="relative">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Öffentliches Profil
</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight">{user.name}</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Dabei seit {user.createdAt.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
</p>
<dl className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<Stat label="Schauzeit" value={formatMinutes(totals.minutes)} />
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
</dl>
</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>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
{recent.map((watch) => (
<li key={watch.id}>
<Poster path={watch.title.posterPath} alt={watch.title.title} size="w185" />
<p className="mt-1.5 line-clamp-2 text-xs font-medium leading-snug">
{watch.title.title}
</p>
<p className="text-xs text-[var(--fl-faint)]">
{formatRelative(watch.watchedAt)}
</p>
</li>
))}
</ul>
</section>
)}
<div className="mt-6 grid gap-6 sm:grid-cols-2">
<TitleList heading="Die meiste Zeit" items={mostWatched} showMinutes />
<TitleList heading="Bestbewertet" items={topRated} />
</div>
{totals.minutes === 0 && (
<p className="fl-card mt-6 p-6 text-center text-sm text-[var(--fl-muted)]">
Hier ist noch nichts zu sehen.
</p>
)}
</main>
<footer className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 text-center text-xs text-[var(--fl-faint)] sm:px-6">
Erstellt mit Flimmer · Metadaten von TMDB
</footer>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
</div>
);
}
function TitleList({
heading,
items,
showMinutes = false,
}: {
heading: string;
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number }[];
showMinutes?: boolean;
}) {
if (items.length === 0) return null;
return (
<section className="fl-card p-5">
<h2 className="mb-4 text-lg font-semibold">{heading}</h2>
<ol className="space-y-3">
{items.map((item, index) => (
<li key={item.titleId} className="flex items-center gap-3">
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
{index + 1}
</span>
<span className="w-10 shrink-0">
<Poster path={item.posterPath} alt={item.title} size="w154" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title}</span>
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
{showMinutes ? formatMinutes(item.minutes) : item.rating ? `${item.rating}/10` : "—"}
</span>
</li>
))}
</ol>
</section>
);
}
+193
View File
@@ -0,0 +1,193 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { getUniverse, suggestCollections } from "@/lib/universes";
import { formatMinutes } from "@/lib/labels";
import { AppShell } from "@/components/AppShell";
import { ProgressBar } from "@/components/ProgressBar";
import { OrderedList } from "@/components/universes/OrderedList";
import {
AddTitles,
DeleteUniverse,
OrderTabs,
SharePanel,
} from "@/components/universes/UniverseTools";
type Params = Promise<{ id: string }>;
type Search = Promise<{ reihenfolge?: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { id } = await params;
const universe = await prisma.universe.findUnique({ where: { id }, select: { name: true } });
return { title: universe?.name ?? "Universum" };
}
export default async function UniversePage({
params,
searchParams,
}: {
params: Params;
searchParams: Search;
}) {
const user = await requireUser();
const settings = await getSettings();
const { id } = await params;
const { reihenfolge } = await searchParams;
const universe = await getUniverse(user.id, id, reihenfolge);
if (!universe) notFound();
const activeOrder = universe.orders.find((order) => order.id === universe.activeOrderId);
const inUniverse = new Set(universe.entries.map((entry) => entry.titleId));
// Vorschläge zum Aufnehmen: alles aus der eigenen Bibliothek, was noch fehlt.
const [libraryTitles, collections] = universe.canWrite
? await Promise.all([
prisma.libraryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { title: { title: "asc" } },
select: {
title: { select: { id: true, title: true, type: true, releaseDate: true } },
},
}),
suggestCollections(user.id),
])
: [[], []];
const candidates = libraryTitles
.map((entry) => entry.title)
.filter((title) => !inUniverse.has(title.id))
.map((title) => ({
id: title.id,
title: title.title,
type: title.type,
year: title.releaseDate?.getFullYear() ?? null,
}));
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Universum
{!universe.isOwner && ` · geteilt von ${universe.ownerName}`}
{!universe.canWrite && " · nur ansehen"}
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">{universe.name}</h1>
{universe.description && (
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">{universe.description}</p>
)}
</header>
<section className="fl-card mb-5 p-5" aria-label="Gesamtfortschritt">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-2xl font-semibold">
{universe.progress.completed}{" "}
<span className="text-base font-normal text-[var(--fl-muted)]">
von {universe.progress.total} abgeschlossen
</span>
</p>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{formatMinutes(universe.progress.watchedMinutes)} gesehen
{universe.progress.remainingMinutes > 0 && (
<>
{" · noch "}
<span className="text-[var(--fl-text)]">
{formatMinutes(universe.progress.remainingMinutes)}
</span>
{" bis zum Ende des Universums"}
</>
)}
</p>
</div>
{universe.nextUp && (
<div className="text-right">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Als Nächstes dran
</p>
<Link
href={`/titel/${universe.nextUp.titleId}`}
className="fl-gradient-text text-lg font-semibold"
>
{universe.nextUp.title}
</Link>
</div>
)}
</div>
<div className="mt-4">
<ProgressBar
percent={universe.progress.percent}
label={`${universe.progress.percent} Prozent des Universums abgeschlossen`}
/>
</div>
</section>
<div className="mb-4">
<OrderTabs universe={universe} canWrite={universe.canWrite} />
{activeOrder?.kind === "RELEASE" ? (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Diese Reihenfolge ergibt sich aus den Erscheinungsdaten und bleibt damit immer aktuell.
</p>
) : universe.canWrite ? (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Ziehen oder die Pfeile benutzen, um umzusortieren — gespeichert wird sofort.
</p>
) : null}
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="order-2 lg:order-1">
<OrderedList
universeId={universe.id}
orderId={universe.activeOrderId}
entries={universe.entries}
sortable={activeOrder?.kind === "CUSTOM"}
canWrite={universe.canWrite}
nextUpItemId={universe.nextUp?.itemId ?? null}
/>
</div>
<aside className="order-1 space-y-5 lg:order-2">
{universe.canWrite && (
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Titel aufnehmen
</h2>
<AddTitles
universeId={universe.id}
candidates={candidates}
collections={collections}
/>
</div>
)}
{universe.isOwner && (
<>
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Teilen
</h2>
<SharePanel universe={universe} />
</div>
<div className="fl-card p-4">
<DeleteUniverse universeId={universe.id} name={universe.name} />
</div>
</>
)}
</aside>
</div>
<p className="mt-8 text-sm">
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Alle Universen
</Link>
</p>
</AppShell>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { AppShell } from "@/components/AppShell";
import { NewUniverseForm } from "@/components/universes/UniverseTools";
export const metadata: Metadata = { title: "Neues Universum" };
export default async function NewUniversePage() {
const user = await requireUser("/universen/neu");
const settings = await getSettings();
return (
<AppShell user={user} instanceName={settings.instanceName}>
<div className="mx-auto max-w-xl">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Neues Universum</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Ein Universum bündelt Filme und Serien, die zusammengehören. Die
Erscheinungsreihenfolge ist von Anfang an dabei; weitere Reihenfolgen legst du
danach an.
</p>
</header>
<div className="fl-card p-5 sm:p-6">
<NewUniverseForm />
</div>
<p className="mt-6 text-sm">
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Zurück zur Übersicht
</Link>
</p>
</div>
</AppShell>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { listUniverses } from "@/lib/universes";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
export const metadata: Metadata = { title: "Universen" };
export default async function UniversesPage() {
const user = await requireUser("/universen");
const settings = await getSettings();
const universes = await listUniverses(user.id);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Universen</h1>
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">
Filme und Serien, die zusammengehören — mit mehreren Reihenfolgen und einem
Gesamtfortschritt.
</p>
</div>
<Link href="/universen/neu" className="fl-btn fl-btn-primary">
Neues Universum
</Link>
</header>
{universes.length === 0 ? (
<div className="fl-card p-8 text-center">
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
Noch kein Universum angelegt. Ein Universum bündelt zusammengehörige Titel — etwa alle
MCU-Filme samt Serien — und zeigt dir, was als Nächstes dran ist.
</p>
<Link href="/universen/neu" className="fl-btn fl-btn-primary mt-4">
Erstes Universum anlegen
</Link>
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{universes.map((universe) => (
<li key={universe.id}>
<Link
href={`/universen/${universe.id}`}
className="fl-card fl-poster-card block overflow-hidden p-4"
>
<div className="mb-3 flex gap-1.5">
{universe.posters.length > 0 ? (
universe.posters.map((path, index) => (
<div key={`${path}-${index}`} className="w-1/5">
<Poster path={path} alt="" size="w154" />
</div>
))
) : (
<div className="w-1/5">
<Poster path={null} alt={universe.name} size="w154" />
</div>
)}
</div>
<h2 className="font-semibold">{universe.name}</h2>
{universe.description && (
<p className="mt-1 line-clamp-2 text-sm text-[var(--fl-muted)]">
{universe.description}
</p>
)}
<p className="mt-2 text-xs text-[var(--fl-faint)]">
{universe.itemCount} {universe.itemCount === 1 ? "Titel" : "Titel"}
{!universe.isOwner ? ` · geteilt von ${universe.ownerName}` : ""}
</p>
<div className="mt-2">
<ProgressBar
percent={universe.percent}
label={`${universe.completed} von ${universe.itemCount} abgeschlossen`}
showValue
/>
</div>
</Link>
</li>
))}
</ul>
)}
</AppShell>
);
}

Some files were not shown because too many files have changed in this diff Show More