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
|
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,
|
||||||
@@ -29,8 +37,13 @@ bleiben auf deinem Server.
|
|||||||
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
|
||||||
|
|
||||||
@@ -165,22 +178,49 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -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",
|
"name": "flimmer",
|
||||||
"version": "0.5.0",
|
"version": "0.6.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,8 +15,9 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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();
|
||||||