Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19f8003945 | ||
|
|
9ee8664233 | ||
|
|
cd9953c47c | ||
|
|
18c87e6b2e | ||
|
|
afa3d74248 | ||
|
|
b3eb946cfa | ||
|
|
29ef710b9b | ||
|
|
3db6241e9a | ||
|
|
d8239441aa | ||
|
|
96a42594a9 | ||
|
|
a54e625ca8 |
@@ -4,6 +4,7 @@ node_modules
|
||||
.env
|
||||
.env.local
|
||||
data
|
||||
backups
|
||||
docs/screenshots
|
||||
*.log
|
||||
test-results
|
||||
|
||||
@@ -27,6 +27,9 @@ AUTH_SECRET=bitte-durch-zufallswert-ersetzen
|
||||
# Ablage für Bild-Cache und sonstige Laufzeitdaten.
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# Port, auf dem Flimmer auf dem Host lauscht.
|
||||
APP_PORT=3000
|
||||
|
||||
# Zeitzone für Kalender, Statistiken und geplante Läufe
|
||||
TZ=Europe/Berlin
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -11,6 +11,7 @@ next-env.d.ts
|
||||
|
||||
# Produktion / Laufzeit
|
||||
data/
|
||||
backups/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Umgebung — niemals committen!
|
||||
@@ -42,3 +43,4 @@ Thumbs.db
|
||||
/blob-report/
|
||||
.playwright/
|
||||
src/generated/
|
||||
.env.composetest
|
||||
|
||||
+89
-1
@@ -7,6 +7,92 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [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
|
||||
|
||||
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.
|
||||
- **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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
| 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 |
|
||||
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
|
||||
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
|
||||
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
|
||||
|
||||
**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
|
||||
|
||||
@@ -111,7 +124,7 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
| ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) |
|
||||
| ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) |
|
||||
| ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) |
|
||||
| ~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 |
|
||||
| ~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.
|
||||
6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build.
|
||||
7. Der SVG-Titel eines Diagramms brach die Hydration.
|
||||
8. Eine Testerwartung zur Reihenfolge war falsch — nicht der Code.
|
||||
|
||||
### Fehler, die erst der echte Containerstart zeigte
|
||||
|
||||
1. Dem Laufzeitimage fehlten transitive Abhängigkeiten der Prisma-CLI, die
|
||||
Migration lief nicht an. Die CLI wird jetzt in einer eigenen Build-Stufe
|
||||
vollständig installiert.
|
||||
2. npm blockiert seit Version 11 Install-Skripte; ohne Freigabe fehlte die
|
||||
plattformpassende Prisma-Engine.
|
||||
3. `next/font/google` lud die Schrift beim Bauen herunter und ließ den Build
|
||||
im Container hängen. Die Schrift liegt jetzt im Repository.
|
||||
|
||||
+22
-3
@@ -16,6 +16,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
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
|
||||
|
||||
# --- Build -------------------------------------------------------------------
|
||||
@@ -32,6 +35,18 @@ ENV NODE_ENV=production
|
||||
|
||||
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 ----------------------------------------------------------------
|
||||
FROM base AS runner
|
||||
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/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`).
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
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
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh \
|
||||
|
||||
@@ -33,6 +33,9 @@ bleiben auf deinem Server.
|
||||
Story-Chronologie, Einsteiger-Route) und Gesamtfortschritt.
|
||||
- **Metadaten von TMDB** — Suche, Poster, Staffeln und Episoden, offizielle
|
||||
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,
|
||||
Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar.
|
||||
- **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen
|
||||
@@ -111,14 +114,31 @@ flimmer.example.com {
|
||||
|
||||
```bash
|
||||
cd flimmer
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
./update.sh
|
||||
```
|
||||
|
||||
Datenbankmigrationen laufen automatisch beim Start des Containers — ein eigener
|
||||
Handgriff ist nicht nötig. **Vor** einem Update ein Backup zu ziehen ist
|
||||
trotzdem eine gute Angewohnheit.
|
||||
Das Skript sichert zuerst die Datenbank, holt die Änderungen, baut das Image neu
|
||||
und wartet, bis die Instanz wieder antwortet. Schlägt etwas fehl, nennt es den
|
||||
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
|
||||
|
||||
@@ -206,6 +226,13 @@ docker/ Entrypoint des Containers
|
||||
docs/ Anleitung mit Screenshots, API, Design-Dokumentation
|
||||
```
|
||||
|
||||
### Automatisierung
|
||||
|
||||
Unter `.gitea/workflows/` liegen zwei Abläufe für Gitea Actions: `ci.yml`
|
||||
prüft bei jedem Push Typen, Tests und Produktionsbuild gegen eine echte
|
||||
PostgreSQL-Instanz, `release.yml` legt beim Pushen eines Versions-Tags das
|
||||
Release an — mit den Notizen aus `releases/vX.Y.Z.md`.
|
||||
|
||||
## Hintergrundlauf
|
||||
|
||||
Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue
|
||||
|
||||
@@ -40,7 +40,7 @@ attempt();
|
||||
'
|
||||
|
||||
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."
|
||||
exec "$@"
|
||||
|
||||
@@ -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
@@ -17,18 +17,20 @@ die echten.
|
||||
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)
|
||||
5. [Hab ich — die eigene Sammlung](#hab-ich--die-eigene-sammlung)
|
||||
6. [Die Bibliothek](#die-bibliothek)
|
||||
7. [Universen](#universen)
|
||||
8. [Kalender](#kalender)
|
||||
9. [Statistik](#statistik)
|
||||
10. [Einstellungen](#einstellungen)
|
||||
11. [Benachrichtigungen](#benachrichtigungen)
|
||||
12. [Import und Export](#import-und-export)
|
||||
13. [API-Schlüssel](#api-schlüssel)
|
||||
14. [Papierkorb](#papierkorb)
|
||||
15. [Verwaltung](#verwaltung)
|
||||
16. [Öffentliches Profil](#öffentliches-profil)
|
||||
17. [Auf dem Handy](#auf-dem-handy)
|
||||
18. [Helles Farbschema](#helles-farbschema)
|
||||
|
||||
---
|
||||
|
||||
@@ -114,6 +116,34 @@ Karte zeigt dann „2. Durchlauf".
|
||||
|
||||
---
|
||||
|
||||
## Hab ich — die eigene Sammlung
|
||||
|
||||

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

|
||||
@@ -320,6 +350,20 @@ sperren.
|
||||
|
||||
---
|
||||
|
||||
## Öffentliches Profil
|
||||
|
||||

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

|
||||
|
||||
+14
-3
@@ -57,8 +57,9 @@ GET /api/v1/bibliothek?status=WATCHING&typ=SERIES&genre=Drama&q=nord&limit=50&o
|
||||
POST /api/v1/bibliothek
|
||||
```
|
||||
|
||||
Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `limit`, `offset`.
|
||||
Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
|
||||
Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `sammlung`, `limit`,
|
||||
`offset`. Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
|
||||
`sammlung=1` liefert nur Titel, die in der eigenen Sammlung liegen.
|
||||
|
||||
Aufnehmen — entweder mit interner Kennung oder direkt mit der TMDB-Kennung:
|
||||
|
||||
@@ -75,7 +76,9 @@ DELETE /api/v1/bibliothek/{entryId}
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
### Sichtungen
|
||||
@@ -161,6 +164,14 @@ GET /api/v1/export?format=csv
|
||||
|
||||
JSON enthält zusätzlich alle Sichtungen und Universen.
|
||||
|
||||
## Beispiel: was ich habe, aber noch nicht gesehen habe
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $KEY" \
|
||||
"$HOST/api/v1/bibliothek?sammlung=1&status=WATCHLIST" \
|
||||
| jq -r '.items[] | "\(.title) — liegt bei: \(.ownedSource // "ohne Angabe")"'
|
||||
```
|
||||
|
||||
## Beispiel: Serien mit offener Episode auflisten
|
||||
|
||||
```bash
|
||||
|
||||
+32
-3
@@ -103,6 +103,30 @@ denselben Variablennamen — keine Änderung an Komponenten.
|
||||
| `.fl-progress` | Fortschrittsbalken im Signaturverlauf |
|
||||
| `.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
|
||||
|
||||
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
|
||||
|
||||
**Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Wird beim Bauen
|
||||
heruntergeladen und mit ausgeliefert; die laufende Instanz fragt nie bei Google
|
||||
an. Rückfallkette: Inter, dann System-Sans.
|
||||
**Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Die variable
|
||||
Fassung (Strichstärke 100–900) liegt als Datei im Repository
|
||||
(`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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
+2
-6
@@ -14,12 +14,8 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
poweredByHeader: false,
|
||||
serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"],
|
||||
// Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über
|
||||
// next/image-Remote-Loader — deshalb hier keine remotePatterns nötig.
|
||||
images: {
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: "attachment",
|
||||
},
|
||||
// Bilder laufen über den eigenen Cache-Endpunkt (/api/image), nicht über
|
||||
// next/image — deshalb ist hier nichts zu konfigurieren.
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
Generated
+2
-14
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flimmer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
@@ -25,7 +25,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/bcryptjs": "3.0.0",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
@@ -1945,17 +1944,6 @@
|
||||
"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": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||
"license": "MIT",
|
||||
@@ -36,7 +36,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/bcryptjs": "3.0.0",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
@@ -51,5 +50,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"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");
|
||||
@@ -289,6 +289,13 @@ model LibraryEntry {
|
||||
/// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar
|
||||
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)
|
||||
|
||||
/// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch …
|
||||
@@ -309,6 +316,7 @@ model LibraryEntry {
|
||||
@@unique([userId, titleId])
|
||||
@@index([userId, status])
|
||||
@@index([userId, updatedAt])
|
||||
@@index([userId, owned])
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -244,6 +244,9 @@ type EntryPlan = {
|
||||
startedDaysAgo?: number;
|
||||
/** Abstand zwischen zwei Episoden in Tagen. */
|
||||
spacingDays?: number;
|
||||
/** Liegt der Titel in der eigenen Sammlung? */
|
||||
owned?: boolean;
|
||||
ownedSource?: string;
|
||||
};
|
||||
|
||||
const ENTRY_PLANS: EntryPlan[] = [
|
||||
@@ -315,8 +318,8 @@ const ENTRY_PLANS: EntryPlan[] = [
|
||||
},
|
||||
{ key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] },
|
||||
|
||||
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340 },
|
||||
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320 },
|
||||
{ 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, owned: true, ownedSource: "Jellyfin" },
|
||||
{ key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 },
|
||||
{ key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] },
|
||||
{ key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] },
|
||||
@@ -330,7 +333,7 @@ const ENTRY_PLANS: EntryPlan[] = [
|
||||
startedDaysAgo: 180,
|
||||
},
|
||||
{ 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: "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,
|
||||
notes: plan.notes ?? null,
|
||||
favorite: plan.favorite ?? false,
|
||||
owned: plan.owned ?? false,
|
||||
ownedSource: plan.ownedSource ?? null,
|
||||
currentRun,
|
||||
startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : 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,
|
||||
notes: plan.notes ?? null,
|
||||
favorite: plan.favorite ?? false,
|
||||
owned: plan.owned ?? false,
|
||||
ownedSource: plan.ownedSource ?? null,
|
||||
currentRun,
|
||||
},
|
||||
});
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
@@ -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.
|
||||
@@ -114,6 +114,8 @@ const detailsSchema = z.object({
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
whereToWatch: z.string().max(120).nullable().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
owned: z.boolean().optional(),
|
||||
ownedSource: z.string().max(80).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function updateEntryAction(entryId: string, data: unknown) {
|
||||
|
||||
@@ -36,6 +36,8 @@ export const GET = withApi(async (_request, user, context) => {
|
||||
rating: entry.rating,
|
||||
notes: entry.notes,
|
||||
whereToWatch: entry.whereToWatch,
|
||||
owned: entry.owned,
|
||||
ownedSource: entry.ownedSource,
|
||||
favorite: entry.favorite,
|
||||
currentRun: entry.currentRun,
|
||||
tags: entry.tags.map((link) => link.tag.name),
|
||||
@@ -58,6 +60,8 @@ const patchSchema = z.object({
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
whereToWatch: z.string().max(120).nullable().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
owned: z.boolean().optional(),
|
||||
ownedSource: z.string().max(80).nullable().optional(),
|
||||
});
|
||||
|
||||
/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */
|
||||
|
||||
@@ -22,6 +22,7 @@ export const GET = withApi(async (request, user) => {
|
||||
genre: url.searchParams.get("genre") ?? undefined,
|
||||
tag: url.searchParams.get("tag") ?? undefined,
|
||||
provider: url.searchParams.get("dienst") ?? undefined,
|
||||
owned: url.searchParams.get("sammlung") === "1" ? true : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -73,6 +73,12 @@ export function GET() {
|
||||
{ name: "genre", in: "query", schema: { type: "string" } },
|
||||
{ name: "tag", in: "query", schema: { type: "string" } },
|
||||
{ name: "dienst", in: "query", schema: { type: "string" } },
|
||||
{
|
||||
name: "sammlung",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description: "Nur Titel, die in der eigenen Sammlung liegen.",
|
||||
},
|
||||
{ name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } },
|
||||
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } },
|
||||
],
|
||||
@@ -123,6 +129,15 @@ export function GET() {
|
||||
notes: { type: "string", nullable: true },
|
||||
whereToWatch: { type: "string", nullable: true },
|
||||
favorite: { type: "boolean" },
|
||||
owned: {
|
||||
type: "boolean",
|
||||
description: "Liegt der Titel in der eigenen Sammlung?",
|
||||
},
|
||||
ownedSource: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description: "Wo genau — Jellyfin, Plex, Blu-ray …",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ export default async function LibraryPage({
|
||||
genre: params.genre || undefined,
|
||||
tag: params.tag || undefined,
|
||||
provider: params.dienst || undefined,
|
||||
owned: params.sammlung === "1" ? true : undefined,
|
||||
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
|
||||
};
|
||||
|
||||
|
||||
+16
-5
@@ -1,5 +1,5 @@
|
||||
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 { ToastProvider } from "@/components/Toasts";
|
||||
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
|
||||
@@ -7,13 +7,24 @@ import { env } from "@/lib/env";
|
||||
import "./globals.css";
|
||||
|
||||
/**
|
||||
* Outfit wird beim Bauen heruntergeladen und mit ausgeliefert — die laufende
|
||||
* Instanz fragt also niemals bei Google an.
|
||||
* Outfit liegt als Datei im Repository.
|
||||
*
|
||||
* 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({
|
||||
subsets: ["latin"],
|
||||
const outfit = localFont({
|
||||
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",
|
||||
variable: "--font-outfit",
|
||||
fallback: ["Inter", "Segoe UI", "system-ui", "sans-serif"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@@ -120,6 +120,19 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
{year ? ` · ${year}` : ""}
|
||||
{title.status ? ` · ${title.status}` : ""}
|
||||
</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 && (
|
||||
<span
|
||||
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]} />
|
||||
) : (
|
||||
<div className="fl-card p-4">
|
||||
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
</div>
|
||||
)}
|
||||
@@ -245,6 +259,8 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
whereToWatch={entry.whereToWatch}
|
||||
tags={entry.tags.map((link) => link.tag.name)}
|
||||
suggestion={suggestion}
|
||||
owned={entry.owned}
|
||||
ownedSource={entry.ownedSource}
|
||||
/>
|
||||
</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)]">
|
||||
Wo läuft das?
|
||||
</h2>
|
||||
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
</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 }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { setTagsAction, updateEntryAction } from "@/app/actions/library";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
|
||||
@@ -17,6 +18,8 @@ export function EntryNotes({
|
||||
whereToWatch,
|
||||
tags,
|
||||
suggestion,
|
||||
owned,
|
||||
ownedSource,
|
||||
}: {
|
||||
entryId: string;
|
||||
notes: string | null;
|
||||
@@ -24,9 +27,12 @@ export function EntryNotes({
|
||||
tags: string[];
|
||||
/** Vorschlag aus der Streaming-Verfügbarkeit. */
|
||||
suggestion: string | null;
|
||||
owned: boolean;
|
||||
ownedSource: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<OwnedEditor entryId={entryId} owned={owned} source={ownedSource} />
|
||||
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
|
||||
<TagEditor entryId={entryId} tags={tags} />
|
||||
<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) {
|
||||
const { report } = useToasts();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -19,8 +19,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
<div className="relative">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||
|
||||
{/* Status und Sammlung teilen sich die obere Zeile — unten sitzt bei
|
||||
den Postern oft der Titel, dort würden sie ihn verdecken. */}
|
||||
<span className="absolute left-2 top-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
|
||||
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
|
||||
style={{
|
||||
color: STATUS_COLOR[status],
|
||||
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
|
||||
@@ -30,6 +33,19 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
{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>
|
||||
|
||||
{item.rating != null && (
|
||||
<span
|
||||
className="absolute right-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
||||
@@ -51,7 +67,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{item.type === "SERIES" ? "Serie" : "Film"}
|
||||
{item.year ? ` · ${item.year}` : ""}
|
||||
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""}
|
||||
{item.owned && item.ownedSource
|
||||
? ` · ${item.ownedSource}`
|
||||
: item.whereToWatch
|
||||
? ` · ${item.whereToWatch}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{showProgress && (
|
||||
|
||||
@@ -56,7 +56,9 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
||||
const status = params.get("status");
|
||||
const type = params.get("typ");
|
||||
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 (
|
||||
<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]} />
|
||||
</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 className="flex flex-wrap items-center gap-2">
|
||||
|
||||
+12
-1
@@ -110,7 +110,14 @@ export async function setStatus(userId: string, entryId: string, status: EntrySt
|
||||
export async function updateEntryDetails(
|
||||
userId: 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);
|
||||
|
||||
@@ -127,6 +134,10 @@ export async function updateEntryDetails(
|
||||
? { whereToWatch: data.whereToWatch?.trim() || null }
|
||||
: {}),
|
||||
...(data.favorite !== undefined ? { favorite: data.favorite } : {}),
|
||||
...(data.owned !== undefined ? { owned: data.owned } : {}),
|
||||
...(data.ownedSource !== undefined
|
||||
? { ownedSource: data.ownedSource?.trim() || null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+12
-1
@@ -19,6 +19,8 @@ export type LibraryFilters = {
|
||||
genre?: string;
|
||||
tag?: string;
|
||||
provider?: string;
|
||||
/** Nur Titel, die in der eigenen Sammlung liegen. */
|
||||
owned?: boolean;
|
||||
sort?: LibrarySort;
|
||||
};
|
||||
|
||||
@@ -42,6 +44,8 @@ export type LibraryItem = {
|
||||
favorite: boolean;
|
||||
currentRun: number;
|
||||
whereToWatch: string | null;
|
||||
owned: boolean;
|
||||
ownedSource: string | null;
|
||||
tags: string[];
|
||||
watched: number;
|
||||
total: number;
|
||||
@@ -56,6 +60,7 @@ export async function getLibrary(
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.owned ? { owned: true } : {}),
|
||||
...(filters.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}),
|
||||
title: {
|
||||
...(filters.type ? { type: filters.type } : {}),
|
||||
@@ -116,6 +121,8 @@ export async function getLibrary(
|
||||
favorite: entry.favorite,
|
||||
currentRun: entry.currentRun,
|
||||
whereToWatch: entry.whereToWatch,
|
||||
owned: entry.owned,
|
||||
ownedSource: entry.ownedSource,
|
||||
tags: entry.tags.map((link) => link.tag.name),
|
||||
watched,
|
||||
total,
|
||||
@@ -162,11 +169,13 @@ export type LibraryFacets = {
|
||||
tags: string[];
|
||||
providers: string[];
|
||||
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. */
|
||||
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 }[]>`
|
||||
SELECT DISTINCT unnest(t."genres") AS genre
|
||||
FROM "LibraryEntry" e
|
||||
@@ -193,6 +202,7 @@ export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.libraryEntry.count({ where: { userId, deletedAt: null } }),
|
||||
prisma.libraryEntry.count({ where: { userId, deletedAt: null, owned: true } }),
|
||||
]);
|
||||
|
||||
const counts = {
|
||||
@@ -211,6 +221,7 @@ export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
|
||||
tags: tagRows.map((row) => row.name),
|
||||
providers: providerRows.map((row) => row.name),
|
||||
counts,
|
||||
owned,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,56 @@ async function notifyAvailability(titleId: string, titleName: string): Promise<n
|
||||
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. */
|
||||
export async function purgeExpired(): Promise<{ entries: number; sessions: number }> {
|
||||
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.");
|
||||
}
|
||||
|
||||
// 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();
|
||||
stats.entriesPurged = purged.entries;
|
||||
stats.sessionsPurged = purged.sessions;
|
||||
|
||||
+18
-1
@@ -135,6 +135,11 @@ const CSV_HEADERS: Record<string, string> = {
|
||||
notiz: "notes",
|
||||
notes: "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,
|
||||
year: record.year ? Number.parseInt(record.year, 10) : 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;
|
||||
else summary.skipped += 1;
|
||||
@@ -299,6 +306,8 @@ type ImportRecord = {
|
||||
rating: number | null;
|
||||
year: number | 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);
|
||||
|
||||
if (record.rating || record.notes) {
|
||||
if (record.rating || record.notes || record.owned || record.ownedSource) {
|
||||
await prisma.libraryEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
rating: record.rating && record.rating >= 1 && record.rating <= 10 ? record.rating : null,
|
||||
notes: record.notes,
|
||||
owned: record.owned ?? false,
|
||||
ownedSource: record.ownedSource ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -394,6 +405,8 @@ export async function exportCsv(userId: string): Promise<string> {
|
||||
"Status",
|
||||
"Bewertung",
|
||||
"Durchlaeufe",
|
||||
"HabIch",
|
||||
"Sammlung",
|
||||
"WoGeschaut",
|
||||
"Tags",
|
||||
"Notiz",
|
||||
@@ -410,6 +423,8 @@ export async function exportCsv(userId: string): Promise<string> {
|
||||
entry.status,
|
||||
entry.rating ?? "",
|
||||
entry.currentRun + 1,
|
||||
entry.owned ? "ja" : "nein",
|
||||
entry.ownedSource ?? "",
|
||||
entry.whereToWatch ?? "",
|
||||
entry.tags.map((link) => link.tag.name).join(" "),
|
||||
entry.notes?.replace(/\r?\n/g, " ") ?? "",
|
||||
@@ -479,6 +494,8 @@ export async function exportJson(userId: string): Promise<object> {
|
||||
rating: entry.rating,
|
||||
notes: entry.notes,
|
||||
whereToWatch: entry.whereToWatch,
|
||||
owned: entry.owned,
|
||||
ownedSource: entry.ownedSource,
|
||||
favorite: entry.favorite,
|
||||
currentRun: entry.currentRun,
|
||||
tags: entry.tags.map((link) => link.tag.name),
|
||||
|
||||
@@ -336,3 +336,55 @@ test("fremde Einträge sind nicht erreichbar", async () => {
|
||||
|
||||
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
@@ -8,7 +8,7 @@ import { prisma } from "../src/lib/db";
|
||||
import { importMovie, importSeries } from "../src/lib/catalog";
|
||||
import { addToLibrary, markMovieWatched, moveToTrash } from "../src/lib/library";
|
||||
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";
|
||||
|
||||
/** 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 } });
|
||||
});
|
||||
|
||||
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 } });
|
||||
});
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user