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.
@@ -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
|
||||
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
|
||||
|
||||
- **Tracking** — Serien und Filme mit Status (Schaue gerade, Pausiert,
|
||||
@@ -29,8 +37,13 @@ bleiben auf deinem Server.
|
||||
Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar.
|
||||
- **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -165,22 +178,49 @@ npm run dev
|
||||
| `npm run db:migrate` | Migration erzeugen und anwenden |
|
||||
| `npm run db:studio` | Prisma Studio |
|
||||
| `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 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:
|
||||
|
||||
```
|
||||
src/
|
||||
app/ Routen (App Router), API-Endpunkte
|
||||
app/ Routen (App Router), API-Endpunkte, Server-Aktionen
|
||||
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
|
||||
prisma/ Schema, Migrationen, Seed
|
||||
prisma/ Schema, Migrationen, Seed samt Beispielkatalog
|
||||
tests/ Testlauf mit nachgebildeten TMDB-Antworten
|
||||
public/brand/ Logo in allen Varianten
|
||||
docker/ Entrypoint des Containers
|
||||
docs/ Anleitung, Design-Dokumentation
|
||||
docs/ Anleitung mit Screenshots, API, Design-Dokumentation
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
Next.js (App Router) mit TypeScript, PostgreSQL über Prisma, Tailwind CSS.
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
<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. [Die Bibliothek](#die-bibliothek)
|
||||
6. [Universen](#universen)
|
||||
7. [Kalender](#kalender)
|
||||
8. [Statistik](#statistik)
|
||||
9. [Einstellungen](#einstellungen)
|
||||
10. [Benachrichtigungen](#benachrichtigungen)
|
||||
11. [Import und Export](#import-und-export)
|
||||
12. [API-Schlüssel](#api-schlüssel)
|
||||
13. [Papierkorb](#papierkorb)
|
||||
14. [Verwaltung](#verwaltung)
|
||||
15. [Auf dem Handy](#auf-dem-handy)
|
||||
16. [Helles Farbschema](#helles-farbschema)
|
||||
|
||||
---
|
||||
|
||||
## Erster Start
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
Ü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
|
||||
|
||||

|
||||
|
||||
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".
|
||||
|
||||
---
|
||||
|
||||
## Die Bibliothek
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
Ein Universum bündelt Titel, die zusammengehören — MCU, Star Wars, Wizarding
|
||||
World — und zwar **Filme und Serien gemeinsam**.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Einstellungen
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Benachrichtigungen
|
||||
|
||||

|
||||
|
||||
*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** 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.
|
||||
|
||||
---
|
||||
|
||||
## API-Schlüssel
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Auf dem Handy
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
Ü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).
|
||||
@@ -0,0 +1,190 @@
|
||||
# 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`, `limit`, `offset`.
|
||||
Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
|
||||
|
||||
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`.
|
||||
`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.
|
||||
|
||||
### 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}
|
||||
```
|
||||
|
||||
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
|
||||
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
|
||||
Erscheinungsreihenfolge.
|
||||
|
||||
### Export
|
||||
|
||||
```http
|
||||
GET /api/v1/export?format=json
|
||||
GET /api/v1/export?format=csv
|
||||
```
|
||||
|
||||
JSON enthält zusätzlich alle Sichtungen und Universen.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||
"license": "MIT",
|
||||
@@ -15,8 +15,9 @@
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"icons": "tsx scripts/generate-icons.ts",
|
||||
"screenshots": "tsx scripts/screenshots.ts",
|
||||
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.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",
|
||||
"tmdb:stub": "node --import tsx scripts/tmdb-stub.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
|
||||
@@ -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`);
|
||||
@@ -0,0 +1,99 @@
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
|
||||
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]")]);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// --- 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();
|
||||