11 Commits
Author SHA1 Message Date
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
43 changed files with 1175 additions and 77 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
+3
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
+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
+89 -1
View File
@@ -7,6 +7,92 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [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 ## [0.6.0] — 2026-09-01
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
@@ -261,7 +347,9 @@ 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.6.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.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.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.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
+26 -2
View File
@@ -91,6 +91,18 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
--- ---
## 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)
---
## Gesamtübersicht ## Gesamtübersicht
| Etappe | Version | Dauer | | Etappe | Version | Dauer |
@@ -101,8 +113,9 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| 4 — Universen | 0.4.0 | 14 min | | 4 — Universen | 0.4.0 | 14 min |
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min | | 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min | | 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
**Gesamtentwicklungszeit: 2 h 16 min** (136 min, 2026-09-01, 18:09 – 20:25) **Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16)
### Womit die Zeit verging ### Womit die Zeit verging
@@ -111,7 +124,7 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) | | ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) |
| ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) | | ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) |
| ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) | | ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) |
| ~20 min | Tests — 49 Stück, die unterwegs sieben echte Fehler gefunden haben | | ~25 min | Tests — 52 Stück, die unterwegs acht echte Fehler gefunden haben |
| ~15 min | Dokumentation, Screenshots, Changelog, Releases | | ~15 min | Dokumentation, Screenshots, Changelog, Releases |
| ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette | | ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette |
@@ -124,3 +137,14 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel. 5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel.
6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build. 6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build.
7. Der SVG-Titel eines Diagramms brach die Hydration. 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 \
+33 -6
View File
@@ -33,6 +33,9 @@ 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
@@ -111,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
@@ -206,6 +226,13 @@ docker/ Entrypoint des Containers
docs/ Anleitung mit Screenshots, API, 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 ## Hintergrundlauf
Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue
+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
}
}
+56 -12
View File
@@ -17,18 +17,20 @@ die echten.
2. [Die Übersicht](#die-übersicht) 2. [Die Übersicht](#die-übersicht)
3. [Titel finden und aufnehmen](#titel-finden-und-aufnehmen) 3. [Titel finden und aufnehmen](#titel-finden-und-aufnehmen)
4. [Eine Serie verfolgen](#eine-serie-verfolgen) 4. [Eine Serie verfolgen](#eine-serie-verfolgen)
5. [Die Bibliothek](#die-bibliothek) 5. [Hab ich — die eigene Sammlung](#hab-ich--die-eigene-sammlung)
6. [Universen](#universen) 6. [Die Bibliothek](#die-bibliothek)
7. [Kalender](#kalender) 7. [Universen](#universen)
8. [Statistik](#statistik) 8. [Kalender](#kalender)
9. [Einstellungen](#einstellungen) 9. [Statistik](#statistik)
10. [Benachrichtigungen](#benachrichtigungen) 10. [Einstellungen](#einstellungen)
11. [Import und Export](#import-und-export) 11. [Benachrichtigungen](#benachrichtigungen)
12. [API-Schlüssel](#api-schlüssel) 12. [Import und Export](#import-und-export)
13. [Papierkorb](#papierkorb) 13. [API-Schlüssel](#api-schlüssel)
14. [Verwaltung](#verwaltung) 14. [Papierkorb](#papierkorb)
15. [Auf dem Handy](#auf-dem-handy) 15. [Verwaltung](#verwaltung)
16. [Helles Farbschema](#helles-farbschema) 16. [Öffentliches Profil](#öffentliches-profil)
17. [Auf dem Handy](#auf-dem-handy)
18. [Helles Farbschema](#helles-farbschema)
--- ---
@@ -114,6 +116,34 @@ 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 ## Die Bibliothek
![Bibliothek](screenshots/05-bibliothek.webp) ![Bibliothek](screenshots/05-bibliothek.webp)
@@ -320,6 +350,20 @@ 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
![Auf dem Handy](screenshots/17-mobil-uebersicht.webp) ![Auf dem Handy](screenshots/17-mobil-uebersicht.webp)
+14 -3
View File
@@ -57,8 +57,9 @@ GET /api/v1/bibliothek?status=WATCHING&typ=SERIES&genre=Drama&q=nord&limit=50&o
POST /api/v1/bibliothek POST /api/v1/bibliothek
``` ```
Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `limit`, `offset`. Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `sammlung`, `limit`,
Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`. `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: Aufnehmen — entweder mit interner Kennung oder direkt mit der TMDB-Kennung:
@@ -75,7 +76,9 @@ DELETE /api/v1/bibliothek/{entryId}
``` ```
`GET` liefert den Eintrag samt `progress` und `nextEpisode`. `GET` liefert den Eintrag samt `progress` und `nextEpisode`.
`PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite`. `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. `DELETE` legt den Eintrag in den Papierkorb — dort bleibt er 30 Tage.
### Sichtungen ### Sichtungen
@@ -161,6 +164,14 @@ GET /api/v1/export?format=csv
JSON enthält zusätzlich alle Sichtungen und Universen. 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 ## Beispiel: Serien mit offener Episode auflisten
```bash ```bash
+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: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 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 [
{ {
+2 -14
View File
@@ -1,12 +1,12 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.1.0", "version": "0.6.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flimmer", "name": "flimmer",
"version": "0.1.0", "version": "0.6.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
@@ -25,7 +25,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",
@@ -1945,17 +1944,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",
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.6.0", "version": "0.7.1",
"private": true, "private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT", "license": "MIT",
@@ -36,7 +36,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",
@@ -51,5 +50,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");
+8
View File
@@ -289,6 +289,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 +316,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])
} }
+10 -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,
}, },
}); });
Binary file not shown.
Binary file not shown.
+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.
+2
View File
@@ -114,6 +114,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) {
@@ -36,6 +36,8 @@ export const GET = withApi(async (_request, user, context) => {
rating: entry.rating, rating: entry.rating,
notes: entry.notes, notes: entry.notes,
whereToWatch: entry.whereToWatch, whereToWatch: entry.whereToWatch,
owned: entry.owned,
ownedSource: entry.ownedSource,
favorite: entry.favorite, favorite: entry.favorite,
currentRun: entry.currentRun, currentRun: entry.currentRun,
tags: entry.tags.map((link) => link.tag.name), tags: entry.tags.map((link) => link.tag.name),
@@ -58,6 +60,8 @@ const patchSchema = 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(),
}); });
/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */ /** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */
+1
View File
@@ -22,6 +22,7 @@ export const GET = withApi(async (request, user) => {
genre: url.searchParams.get("genre") ?? undefined, genre: url.searchParams.get("genre") ?? undefined,
tag: url.searchParams.get("tag") ?? undefined, tag: url.searchParams.get("tag") ?? undefined,
provider: url.searchParams.get("dienst") ?? undefined, provider: url.searchParams.get("dienst") ?? undefined,
owned: url.searchParams.get("sammlung") === "1" ? true : undefined,
}); });
return { return {
+15
View File
@@ -73,6 +73,12 @@ export function GET() {
{ name: "genre", in: "query", schema: { type: "string" } }, { name: "genre", in: "query", schema: { type: "string" } },
{ name: "tag", in: "query", schema: { type: "string" } }, { name: "tag", in: "query", schema: { type: "string" } },
{ name: "dienst", 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: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } },
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } }, { name: "offset", in: "query", schema: { type: "integer", default: 0 } },
], ],
@@ -123,6 +129,15 @@ export function GET() {
notes: { type: "string", nullable: true }, notes: { type: "string", nullable: true },
whereToWatch: { type: "string", nullable: true }, whereToWatch: { type: "string", nullable: true },
favorite: { type: "boolean" }, 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 …",
},
}, },
}, },
}, },
+1
View File
@@ -29,6 +29,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,
}; };
+16 -5
View File
@@ -1,5 +1,5 @@
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 { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
@@ -7,13 +7,24 @@ 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 = {
+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>
+95 -1
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useEffect, useRef, useState, useTransition } from "react"; import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import { setTagsAction, updateEntryAction } from "@/app/actions/library"; import { setTagsAction, updateEntryAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts"; import { useToasts } from "@/components/Toasts";
@@ -17,6 +18,8 @@ export function EntryNotes({
whereToWatch, whereToWatch,
tags, tags,
suggestion, suggestion,
owned,
ownedSource,
}: { }: {
entryId: string; entryId: string;
notes: string | null; notes: string | null;
@@ -24,9 +27,12 @@ export function EntryNotes({
tags: string[]; tags: string[];
/** Vorschlag aus der Streaming-Verfügbarkeit. */ /** Vorschlag aus der Streaming-Verfügbarkeit. */
suggestion: string | null; suggestion: string | null;
owned: boolean;
ownedSource: string | null;
}) { }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<OwnedEditor entryId={entryId} owned={owned} source={ownedSource} />
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} /> <WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
<TagEditor entryId={entryId} tags={tags} /> <TagEditor entryId={entryId} tags={tags} />
<Notes entryId={entryId} value={notes} /> <Notes entryId={entryId} value={notes} />
@@ -34,6 +40,94 @@ export function EntryNotes({
); );
} }
/**
* „Hab ich" — der Titel liegt in der eigenen Sammlung.
*
* Bewusst getrennt von „wo schaue ich das": Streaming-Verfügbarkeit kommt und
* geht, der gekaufte Film auf dem Heimserver bleibt. Genau die Titel, die
* nirgends mehr laufen, will man ja wiederfinden.
*/
function OwnedEditor({
entryId,
owned,
source,
}: {
entryId: string;
owned: boolean;
source: string | null;
}) {
const save = useAutosave(entryId);
const [isOwned, setIsOwned] = useState(owned);
const [text, setText] = useState(source ?? "");
const initial = useRef(source ?? "");
return (
<div className="space-y-2 rounded-xl border border-[var(--fl-border)] p-3">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={isOwned}
onChange={(event) => {
setIsOwned(event.target.checked);
save({ owned: event.target.checked });
}}
className="sr-only"
/>
<span
aria-hidden
className={clsx(
"mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-all duration-200",
isOwned ? "border-transparent" : "border-[var(--fl-border-strong)]",
)}
style={isOwned ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{isOwned && (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="var(--fl-on-accent)" strokeWidth="3.5">
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">Hab ich</span>
<span className="block text-xs text-[var(--fl-faint)]">
Liegt in der eigenen Sammlung — unabhängig davon, ob es gerade irgendwo läuft.
</span>
</span>
</label>
{isOwned && (
<div className="pl-8">
<label htmlFor="ownedSource" className="sr-only">
Wo liegt der Titel?
</label>
<input
id="ownedSource"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ ownedSource: text });
}}
list="fl-owned-quellen"
placeholder="Jellyfin, Plex, NAS, Blu-ray …"
className="fl-input py-1.5 text-sm"
/>
<datalist id="fl-owned-quellen">
<option value="Jellyfin" />
<option value="Plex" />
<option value="Emby" />
<option value="NAS" />
<option value="Blu-ray" />
<option value="DVD" />
<option value="Gekauft (digital)" />
</datalist>
</div>
)}
</div>
);
}
function useAutosave(entryId: string) { function useAutosave(entryId: string) {
const { report } = useToasts(); const { report } = useToasts();
const router = useRouter(); const router = useRouter();
+30 -10
View File
@@ -19,15 +19,31 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
<div className="relative"> <div className="relative">
<Poster path={item.posterPath} alt={item.title} size="w342" /> <Poster path={item.posterPath} alt={item.title} size="w342" />
<span {/* Status und Sammlung teilen sich die obere Zeile — unten sitzt bei
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md" den Postern oft der Titel, dort würden sie ihn verdecken. */}
style={{ <span className="absolute left-2 top-2 flex flex-wrap gap-1">
color: STATUS_COLOR[status], <span
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`, className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`, style={{
}} color: STATUS_COLOR[status],
> backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
{STATUS_LABEL[status]} boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
}}
>
{STATUS_LABEL[status]}
</span>
{item.owned && (
<span
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
title={
item.ownedSource ? `In deiner Sammlung: ${item.ownedSource}` : "In deiner Sammlung"
}
>
Hab ich
</span>
)}
</span> </span>
{item.rating != null && ( {item.rating != null && (
@@ -51,7 +67,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
<p className="text-xs text-[var(--fl-faint)]"> <p className="text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"} {item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""} {item.year ? ` · ${item.year}` : ""}
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""} {item.owned && item.ownedSource
? ` · ${item.ownedSource}`
: item.whereToWatch
? ` · ${item.whereToWatch}`
: ""}
</p> </p>
{showProgress && ( {showProgress && (
+12 -1
View File
@@ -56,7 +56,9 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const status = params.get("status"); const status = params.get("status");
const type = params.get("typ"); const type = params.get("typ");
const sort = params.get("sortierung") ?? "aktualisiert"; const sort = params.get("sortierung") ?? "aktualisiert";
const activeCount = ["genre", "tag", "dienst", "typ"].filter((key) => params.get(key)).length; const activeCount = ["genre", "tag", "dienst", "typ", "sammlung"].filter((key) =>
params.get(key),
).length;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -78,6 +80,15 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
{STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} /> {STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} />
</Chip> </Chip>
))} ))}
{facets.owned > 0 && (
<Chip
active={params.get("sammlung") === "1"}
onClick={() => apply({ sammlung: params.get("sammlung") === "1" ? null : "1" })}
>
Hab ich <Count value={facets.owned} />
</Chip>
)}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
+12 -1
View File
@@ -110,7 +110,14 @@ export async function setStatus(userId: string, entryId: string, status: EntrySt
export async function updateEntryDetails( export async function updateEntryDetails(
userId: string, userId: string,
entryId: string, entryId: string,
data: { rating?: number | null; notes?: string | null; whereToWatch?: string | null; favorite?: boolean }, data: {
rating?: number | null;
notes?: string | null;
whereToWatch?: string | null;
favorite?: boolean;
owned?: boolean;
ownedSource?: string | null;
},
) { ) {
await requireEntry(userId, entryId); await requireEntry(userId, entryId);
@@ -127,6 +134,10 @@ export async function updateEntryDetails(
? { whereToWatch: data.whereToWatch?.trim() || null } ? { whereToWatch: data.whereToWatch?.trim() || null }
: {}), : {}),
...(data.favorite !== undefined ? { favorite: data.favorite } : {}), ...(data.favorite !== undefined ? { favorite: data.favorite } : {}),
...(data.owned !== undefined ? { owned: data.owned } : {}),
...(data.ownedSource !== undefined
? { ownedSource: data.ownedSource?.trim() || null }
: {}),
}, },
}); });
} }
+12 -1
View File
@@ -19,6 +19,8 @@ export type LibraryFilters = {
genre?: string; genre?: string;
tag?: string; tag?: string;
provider?: string; provider?: string;
/** Nur Titel, die in der eigenen Sammlung liegen. */
owned?: boolean;
sort?: LibrarySort; sort?: LibrarySort;
}; };
@@ -42,6 +44,8 @@ export type LibraryItem = {
favorite: boolean; favorite: boolean;
currentRun: number; currentRun: number;
whereToWatch: string | null; whereToWatch: string | null;
owned: boolean;
ownedSource: string | null;
tags: string[]; tags: string[];
watched: number; watched: number;
total: number; total: number;
@@ -56,6 +60,7 @@ export async function getLibrary(
userId, userId,
deletedAt: null, deletedAt: null,
...(filters.status ? { status: filters.status } : {}), ...(filters.status ? { status: filters.status } : {}),
...(filters.owned ? { owned: true } : {}),
...(filters.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}), ...(filters.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}),
title: { title: {
...(filters.type ? { type: filters.type } : {}), ...(filters.type ? { type: filters.type } : {}),
@@ -116,6 +121,8 @@ export async function getLibrary(
favorite: entry.favorite, favorite: entry.favorite,
currentRun: entry.currentRun, currentRun: entry.currentRun,
whereToWatch: entry.whereToWatch, whereToWatch: entry.whereToWatch,
owned: entry.owned,
ownedSource: entry.ownedSource,
tags: entry.tags.map((link) => link.tag.name), tags: entry.tags.map((link) => link.tag.name),
watched, watched,
total, total,
@@ -162,11 +169,13 @@ export type LibraryFacets = {
tags: string[]; tags: string[];
providers: string[]; providers: string[];
counts: Record<EntryStatus | "ALLE", number>; counts: Record<EntryStatus | "ALLE", number>;
/** Wie viele Titel in der eigenen Sammlung liegen. */
owned: number;
}; };
/** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */ /** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */
export async function getLibraryFacets(userId: string): Promise<LibraryFacets> { export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
const [genreRows, tagRows, providerRows, statusRows, total] = await Promise.all([ const [genreRows, tagRows, providerRows, statusRows, total, owned] = await Promise.all([
prisma.$queryRaw<{ genre: string }[]>` prisma.$queryRaw<{ genre: string }[]>`
SELECT DISTINCT unnest(t."genres") AS genre SELECT DISTINCT unnest(t."genres") AS genre
FROM "LibraryEntry" e FROM "LibraryEntry" e
@@ -193,6 +202,7 @@ export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
_count: { _all: true }, _count: { _all: true },
}), }),
prisma.libraryEntry.count({ where: { userId, deletedAt: null } }), prisma.libraryEntry.count({ where: { userId, deletedAt: null } }),
prisma.libraryEntry.count({ where: { userId, deletedAt: null, owned: true } }),
]); ]);
const counts = { const counts = {
@@ -211,6 +221,7 @@ export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
tags: tagRows.map((row) => row.name), tags: tagRows.map((row) => row.name),
providers: providerRows.map((row) => row.name), providers: providerRows.map((row) => row.name),
counts, counts,
owned,
}; };
} }
+60
View File
@@ -151,6 +151,56 @@ async function notifyAvailability(titleId: string, titleName: string): Promise<n
return sent; return sent;
} }
/**
* Meldet Filme von der Watchlist, die gerade erschienen sind.
*
* Betrachtet wird ein Fenster 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 — dafür sorgt der Dedupe-Schlüssel.
*/
async function notifyReleases(windowDays = 7): Promise<number> {
const from = new Date(Date.now() - windowDays * 86_400_000);
const to = new Date();
const titles = await prisma.title.findMany({
where: {
type: "MOVIE",
releaseDate: { gte: from, lte: to },
entries: { some: { deletedAt: null, status: "WATCHLIST" } },
},
select: { id: true, title: true, releaseDate: true },
});
let sent = 0;
for (const title of titles) {
const watchers = await prisma.libraryEntry.findMany({
where: {
titleId: title.id,
deletedAt: null,
status: "WATCHLIST",
user: { notifyRelease: true, isActive: true },
},
select: { userId: true },
});
for (const watcher of watchers) {
const result = await notifyUser(watcher.userId, {
subject: `Jetzt erschienen: ${title.title}`,
body: `„${title.title}" von deiner Watchlist ist seit dem ${title.releaseDate?.toLocaleDateString(
"de-DE",
{ day: "2-digit", month: "2-digit", year: "numeric" },
)} draußen.`,
path: `/titel/${title.id}`,
dedupeKey: `release:${title.id}`,
});
sent += result.sent;
}
}
return sent;
}
/** Räumt den Papierkorb und abgelaufene Sitzungen. */ /** Räumt den Papierkorb und abgelaufene Sitzungen. */
export async function purgeExpired(): Promise<{ entries: number; sessions: number }> { export async function purgeExpired(): Promise<{ entries: number; sessions: number }> {
const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 86_400_000); const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 86_400_000);
@@ -223,6 +273,16 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
stats.errors.push("Kein TMDB-Schlüssel hinterlegt — der Abgleich wurde übersprungen."); stats.errors.push("Kein TMDB-Schlüssel hinterlegt — der Abgleich wurde übersprungen.");
} }
// Erscheinungsmeldungen hängen nicht am TMDB-Abruf: sie stützen sich auf
// die Erscheinungsdaten, die schon im Katalog stehen.
try {
stats.notificationsSent += await notifyReleases();
} catch (error) {
stats.errors.push(
`Erscheinungsmeldungen: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
);
}
const purged = await purgeExpired(); const purged = await purgeExpired();
stats.entriesPurged = purged.entries; stats.entriesPurged = purged.entries;
stats.sessionsPurged = purged.sessions; stats.sessionsPurged = purged.sessions;
+18 -1
View File
@@ -135,6 +135,11 @@ const CSV_HEADERS: Record<string, string> = {
notiz: "notes", notiz: "notes",
notes: "notes", notes: "notes",
kommentar: "notes", kommentar: "notes",
habich: "owned",
owned: "owned",
besitz: "owned",
sammlung: "ownedSource",
quelle: "ownedSource",
}; };
/** /**
@@ -185,6 +190,8 @@ export async function importCsv(userId: string, text: string): Promise<ImportSum
rating: record.rating ? Number.parseInt(record.rating, 10) : null, rating: record.rating ? Number.parseInt(record.rating, 10) : null,
year: record.year ? Number.parseInt(record.year, 10) : null, year: record.year ? Number.parseInt(record.year, 10) : null,
notes: record.notes || null, notes: record.notes || null,
owned: /^(ja|yes|true|1|x)$/i.test(record.owned ?? ""),
ownedSource: record.ownedSource || null,
}); });
if (result === "added") summary.added += 1; if (result === "added") summary.added += 1;
else summary.skipped += 1; else summary.skipped += 1;
@@ -299,6 +306,8 @@ type ImportRecord = {
rating: number | null; rating: number | null;
year: number | null; year: number | null;
notes: string | null; notes: string | null;
owned?: boolean;
ownedSource?: string | null;
}; };
/** /**
@@ -353,12 +362,14 @@ async function importOne(userId: string, record: ImportRecord): Promise<"added"
const entry = await addToLibrary(userId, titleId, record.status); const entry = await addToLibrary(userId, titleId, record.status);
if (record.rating || record.notes) { if (record.rating || record.notes || record.owned || record.ownedSource) {
await prisma.libraryEntry.update({ await prisma.libraryEntry.update({
where: { id: entry.id }, where: { id: entry.id },
data: { data: {
rating: record.rating && record.rating >= 1 && record.rating <= 10 ? record.rating : null, rating: record.rating && record.rating >= 1 && record.rating <= 10 ? record.rating : null,
notes: record.notes, notes: record.notes,
owned: record.owned ?? false,
ownedSource: record.ownedSource ?? null,
}, },
}); });
} }
@@ -394,6 +405,8 @@ export async function exportCsv(userId: string): Promise<string> {
"Status", "Status",
"Bewertung", "Bewertung",
"Durchlaeufe", "Durchlaeufe",
"HabIch",
"Sammlung",
"WoGeschaut", "WoGeschaut",
"Tags", "Tags",
"Notiz", "Notiz",
@@ -410,6 +423,8 @@ export async function exportCsv(userId: string): Promise<string> {
entry.status, entry.status,
entry.rating ?? "", entry.rating ?? "",
entry.currentRun + 1, entry.currentRun + 1,
entry.owned ? "ja" : "nein",
entry.ownedSource ?? "",
entry.whereToWatch ?? "", entry.whereToWatch ?? "",
entry.tags.map((link) => link.tag.name).join(" "), entry.tags.map((link) => link.tag.name).join(" "),
entry.notes?.replace(/\r?\n/g, " ") ?? "", entry.notes?.replace(/\r?\n/g, " ") ?? "",
@@ -479,6 +494,8 @@ export async function exportJson(userId: string): Promise<object> {
rating: entry.rating, rating: entry.rating,
notes: entry.notes, notes: entry.notes,
whereToWatch: entry.whereToWatch, whereToWatch: entry.whereToWatch,
owned: entry.owned,
ownedSource: entry.ownedSource,
favorite: entry.favorite, favorite: entry.favorite,
currentRun: entry.currentRun, currentRun: entry.currentRun,
tags: entry.tags.map((link) => link.tag.name), tags: entry.tags.map((link) => link.tag.name),
+52
View File
@@ -336,3 +336,55 @@ test("fremde Einträge sind nicht erreichbar", async () => {
await prisma.user.delete({ where: { id: other.id } }); await prisma.user.delete({ where: { id: other.id } });
}); });
test("Sammlung ist unabhängig von der Streaming-Verfügbarkeit", async () => {
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
const fresh = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
assert.equal(fresh.owned, false, "standardmäßig gehört einem nichts");
assert.equal(fresh.ownedSource, null);
const owned = await updateEntryDetails(userId, entry.id, {
owned: true,
ownedSource: " Jellyfin ",
});
assert.equal(owned.owned, true);
assert.equal(owned.ownedSource, "Jellyfin", "Rand wird beschnitten");
// Der Fundort bleibt davon unberührt: das eine sagt, wo es läuft,
// das andere, wo es bei mir liegt.
assert.equal(
owned.whereToWatch,
"Disney Plus",
"der Vorschlag aus der Verfügbarkeit bleibt davon unberührt",
);
const released = await updateEntryDetails(userId, entry.id, {
owned: false,
ownedSource: "",
});
assert.equal(released.owned, false);
assert.equal(released.ownedSource, null, "leerer Text wird zu null");
});
test("Sammlung: Filter und Zähler in der Bibliothek", async () => {
const movie = await addToLibrary(userId, movieTitleId, "WATCHLIST");
const series = await addToLibrary(userId, seriesTitleId, "WATCHING");
await updateEntryDetails(userId, movie.id, { owned: true, ownedSource: "Blu-ray" });
const { getLibrary, getLibraryFacets } = await import("../src/lib/queries");
const alle = await getLibrary(userId, {});
assert.equal(alle.length, 2);
const nurSammlung = await getLibrary(userId, { owned: true });
assert.equal(nurSammlung.length, 1);
assert.equal(nurSammlung[0]?.entryId, movie.id);
assert.equal(nurSammlung[0]?.ownedSource, "Blu-ray");
const facets = await getLibraryFacets(userId);
assert.equal(facets.owned, 1, "der Zähler der Filterleiste stimmt");
assert.equal(facets.counts.ALLE, 2);
await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } });
});
+37 -1
View File
@@ -8,7 +8,7 @@ import { prisma } from "../src/lib/db";
import { importMovie, importSeries } from "../src/lib/catalog"; import { importMovie, importSeries } from "../src/lib/catalog";
import { addToLibrary, markMovieWatched, moveToTrash } from "../src/lib/library"; import { addToLibrary, markMovieWatched, moveToTrash } from "../src/lib/library";
import { exportCsv, exportJson, importCsv, importTrakt, parseCsv } from "../src/lib/transfer"; import { exportCsv, exportJson, importCsv, importTrakt, parseCsv } from "../src/lib/transfer";
import { purgeExpired, TRASH_RETENTION_DAYS } from "../src/lib/sync"; import { purgeExpired, runSync, TRASH_RETENTION_DAYS } from "../src/lib/sync";
import { notifyUser } from "../src/lib/notify"; import { notifyUser } from "../src/lib/notify";
/** Prüft Import, Export, das Aufräumen des Papierkorbs und den Doppelversandschutz. */ /** Prüft Import, Export, das Aufräumen des Papierkorbs und den Doppelversandschutz. */
@@ -239,3 +239,39 @@ test("dieselbe Nachricht wird nicht zweimal verschickt", async () => {
await prisma.notificationChannel.deleteMany({ where: { userId } }); await prisma.notificationChannel.deleteMany({ where: { userId } });
}); });
test("erschienene Watchlist-Filme werden gemeldet — und nur einmal", async () => {
// Der Film liegt auf der Watchlist und ist gestern erschienen.
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await prisma.title.update({
where: { id: movieTitleId },
data: { releaseDate: new Date(Date.now() - 86_400_000) },
});
await prisma.user.update({ where: { id: userId }, data: { notifyRelease: true } });
await prisma.notificationChannel.create({
data: {
userId,
kind: "WEBHOOK",
name: "Test",
config: { webhookUrl: "http://127.0.0.1:9/nicht-erreichbar" },
},
});
await runSync({ limit: 0 });
const first = await prisma.notificationLog.findMany({
where: { userId, dedupeKey: `release:${movieTitleId}` },
});
assert.equal(first.length, 1, "die Meldung wird angelegt");
assert.match(first[0]?.subject ?? "", /Jetzt erschienen/);
// Ein zweiter Lauf meldet dasselbe nicht noch einmal.
await runSync({ limit: 0 });
const second = await prisma.notificationLog.count({
where: { userId, dedupeKey: `release:${movieTitleId}` },
});
assert.equal(second, 1);
await prisma.notificationChannel.deleteMany({ where: { userId } });
await prisma.libraryEntry.delete({ where: { id: entry.id } });
});
Executable
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env bash
#
# Flimmer aktualisieren.
#
# ./update.sh sichern, holen, bauen, neu starten
# ./update.sh --ohne-backup ohne Datenbanksicherung
# ./update.sh --ohne-pull nur neu bauen und starten (kein git pull)
# ./update.sh --hilfe
#
# Grundhaltung: Erst sichern, dann anfassen. Bricht etwas ab, steht am Ende,
# wie der Weg zurück aussieht — mit dem konkreten Befehl und der konkreten
# Sicherungsdatei, nicht bloß dem Rat, ein Backup zu haben.
set -euo pipefail
# --- Ausgabe -----------------------------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
AKZENT=$'\033[38;5;214m'; GUT=$'\033[32m'; WARN=$'\033[33m'
FEHLER=$'\033[31m'; LEISE=$'\033[2m'; AUS=$'\033[0m'
else
AKZENT=""; GUT=""; WARN=""; FEHLER=""; LEISE=""; AUS=""
fi
schritt() { printf '\n%s▸ %s%s\n' "$AKZENT" "$1" "$AUS"; }
info() { printf ' %s\n' "$1"; }
leise() { printf ' %s%s%s\n' "$LEISE" "$1" "$AUS"; }
gut() { printf ' %s✓ %s%s\n' "$GUT" "$1" "$AUS"; }
warnung() { printf ' %s! %s%s\n' "$WARN" "$1" "$AUS"; }
abbruch() { printf '\n%s✗ %s%s\n' "$FEHLER" "$1" "$AUS" >&2; exit 1; }
hilfe() {
cat <<'ENDE'
Flimmer aktualisieren
./update.sh [OPTIONEN]
Ablauf: Datenbank sichern → Änderungen holen → Image bauen → neu starten →
warten, bis die Instanz wieder antwortet. Die Datenbankmigrationen spielt der
Container beim Start selbst ein.
Optionen:
--ohne-backup Keine Sicherung anlegen (nicht empfohlen)
--ohne-pull Nichts aus dem Repository holen, nur neu bauen und starten
--behalte N Wie viele Sicherungen aufgehoben werden (Standard: 10)
--hilfe, -h Diese Übersicht
Die Sicherungen liegen unter backups/ und sind gzip-komprimierte pg_dumps.
ENDE
}
# --- Optionen ----------------------------------------------------------------
MIT_BACKUP=1
MIT_PULL=1
BEHALTE=10
while [ $# -gt 0 ]; do
case "$1" in
--ohne-backup) MIT_BACKUP=0 ;;
--ohne-pull) MIT_PULL=0 ;;
--behalte) shift; BEHALTE="${1:-10}" ;;
--hilfe|-h) hilfe; exit 0 ;;
*) abbruch "Unbekannte Option: $1 (./update.sh --hilfe)" ;;
esac
shift
done
# --- Voraussetzungen ---------------------------------------------------------
cd "$(dirname "$0")"
[ -f docker-compose.yml ] || abbruch "Hier liegt keine docker-compose.yml. Das Skript gehört ins Flimmer-Verzeichnis."
[ -f .env ] || abbruch "Es fehlt die .env. Lege sie an: cp .env.example .env"
command -v docker >/dev/null 2>&1 || abbruch "docker ist nicht installiert oder nicht im Pfad."
if docker compose version >/dev/null 2>&1; then
COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE="docker-compose"
else
abbruch "Weder 'docker compose' noch 'docker-compose' gefunden."
fi
docker info >/dev/null 2>&1 || abbruch "Der Docker-Dienst antwortet nicht. Läuft er?"
# Werte aus der .env lesen, ohne sie auszuführen.
env_wert() {
local schluessel="$1" standard="${2:-}"
local zeile
zeile=$(grep -E "^[[:space:]]*${schluessel}=" .env 2>/dev/null | tail -1 || true)
if [ -z "$zeile" ]; then printf '%s' "$standard"; return; fi
zeile="${zeile#*=}"
zeile="${zeile%\"}"; zeile="${zeile#\"}"
zeile="${zeile%\'}"; zeile="${zeile#\'}"
printf '%s' "$zeile"
}
DB_USER=$(env_wert POSTGRES_USER flimmer)
DB_NAME=$(env_wert POSTGRES_DB flimmer)
PORT=$(env_wert APP_PORT 3000)
printf '%s\n' "Flimmer aktualisieren"
leise "Verzeichnis: $(pwd)"
leise "Compose: $COMPOSE"
# --- Stand festhalten --------------------------------------------------------
IST_GIT=0
VORHER_REF=""
if [ -d .git ] && command -v git >/dev/null 2>&1; then
IST_GIT=1
VORHER_REF=$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")
fi
VORHER_VERSION=$(grep -m1 '"version"' package.json 2>/dev/null | sed 's/.*"version"[^"]*"\([^"]*\)".*/\1/' || echo "?")
leise "Aktuell: Version $VORHER_VERSION${VORHER_REF:+ (${VORHER_REF})}"
# --- Sicherung ---------------------------------------------------------------
SICHERUNG=""
if [ "$MIT_BACKUP" -eq 1 ]; then
schritt "Datenbank sichern"
# -q gibt es in beiden Compose-Fassungen; --status erst ab v2.
if [ -z "$($COMPOSE ps -q db 2>/dev/null)" ]; then
warnung "Die Datenbank läuft nicht — es gibt also auch nichts zu sichern."
else
mkdir -p backups
SICHERUNG="backups/flimmer-$(date +%Y-%m-%d-%H%M%S).sql.gz"
if $COMPOSE exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists \
| gzip > "$SICHERUNG" 2>/dev/null && [ -s "$SICHERUNG" ]; then
gut "$SICHERUNG ($(du -h "$SICHERUNG" | cut -f1))"
else
rm -f "$SICHERUNG"
SICHERUNG=""
abbruch "Die Sicherung ist fehlgeschlagen. Abbruch — ohne Sicherung wird hier nichts aktualisiert.
Wenn du es trotzdem willst: ./update.sh --ohne-backup"
fi
# Ältere Sicherungen aufräumen, die jüngsten bleiben.
if [ "$BEHALTE" -gt 0 ]; then
ANZAHL=$(ls -1 backups/flimmer-*.sql.gz 2>/dev/null | wc -l)
if [ "$ANZAHL" -gt "$BEHALTE" ]; then
ls -1t backups/flimmer-*.sql.gz | tail -n +$((BEHALTE + 1)) | xargs -r rm --
leise "Ältere Sicherungen entfernt, die letzten $BEHALTE bleiben."
fi
fi
fi
else
warnung "Ohne Sicherung — auf eigenes Risiko."
fi
# --- Änderungen holen --------------------------------------------------------
if [ "$MIT_PULL" -eq 1 ]; then
schritt "Änderungen holen"
if [ "$IST_GIT" -eq 0 ]; then
warnung "Kein Git-Repository — übersprungen."
elif [ -n "$(git status --porcelain 2>/dev/null)" ]; then
warnung "Es gibt lokale Änderungen; git pull wird übersprungen."
leise "Ansehen mit: git status"
else
ZWEIG=$(git rev-parse --abbrev-ref HEAD)
if git pull --ff-only 2>&1 | sed 's/^/ /'; then
NACHHER_REF=$(git rev-parse --short HEAD)
if [ "$NACHHER_REF" = "$VORHER_REF" ]; then
gut "Schon auf dem neuesten Stand ($ZWEIG)."
else
gut "$VORHER_REF → $NACHHER_REF"
fi
else
abbruch "git pull ist fehlgeschlagen. Vermutlich sind Zweig und Gegenstelle auseinandergelaufen."
fi
fi
fi
NEUE_VERSION=$(grep -m1 '"version"' package.json 2>/dev/null | sed 's/.*"version"[^"]*"\([^"]*\)".*/\1/' || echo "?")
# --- Bauen und starten -------------------------------------------------------
schritt "Image bauen"
info "Das dauert beim ersten Mal einige Minuten."
$COMPOSE build 2>&1 | sed 's/^/ /' || abbruch "Der Build ist fehlgeschlagen. Die laufende Instanz ist unangetastet."
gut "Gebaut."
schritt "Neu starten"
$COMPOSE up -d 2>&1 | sed 's/^/ /' || abbruch "Der Start ist fehlgeschlagen. Log ansehen: $COMPOSE logs -f app"
gut "Container laufen."
# --- Warten, bis die Instanz antwortet ---------------------------------------
schritt "Auf die Instanz warten"
info "Die Datenbankmigrationen spielt der Container beim Start selbst ein."
GESUND=0
for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then
GESUND=1
break
fi
sleep 2
done
if [ "$GESUND" -eq 1 ]; then
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true)
gut "Flimmer antwortet auf Port ${PORT}."
leise "$ANTWORT"
printf '\n%s✓ Fertig — Version %s%s\n' "$GUT" "$NEUE_VERSION" "$AUS"
if [ "$VORHER_VERSION" != "$NEUE_VERSION" ]; then
info "Was sich geändert hat, steht in CHANGELOG.md."
fi
[ -n "$SICHERUNG" ] && leise "Sicherung: $SICHERUNG"
exit 0
fi
# --- Fehlschlag: konkreter Weg zurück ----------------------------------------
printf '\n%s✗ Die Instanz antwortet nach zwei Minuten nicht.%s\n\n' "$FEHLER" "$AUS" >&2
info "Log ansehen:"
printf ' %s logs --tail 50 app\n\n' "$COMPOSE"
if [ -n "$SICHERUNG" ] || [ -n "$VORHER_REF" ]; then
info "Weg zurück:"
if [ "$IST_GIT" -eq 1 ] && [ -n "$VORHER_REF" ]; then
printf ' git checkout %s && %s up -d --build\n' "$VORHER_REF" "$COMPOSE"
fi
if [ -n "$SICHERUNG" ]; then
printf ' gunzip -c %s | %s exec -T db psql -U %s -d %s\n' \
"$SICHERUNG" "$COMPOSE" "$DB_USER" "$DB_NAME"
fi
printf '\n'
fi
exit 1