Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19f8003945 | ||
|
|
9ee8664233 | ||
|
|
cd9953c47c | ||
|
|
18c87e6b2e | ||
|
|
afa3d74248 | ||
|
|
b3eb946cfa | ||
|
|
29ef710b9b | ||
|
|
3db6241e9a | ||
|
|
d8239441aa | ||
|
|
96a42594a9 | ||
|
|
a54e625ca8 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# 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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 +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
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
|
|||||||
@@ -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)
|
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
|
||||||
|
|
||||||
|

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

|

|
||||||
@@ -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
|
## 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
|
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
@@ -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
@@ -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 [
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+2
-14
@@ -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
@@ -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");
|
||||||
@@ -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
@@ -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.
@@ -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(),
|
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. */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 …",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ 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" />
|
||||||
|
|
||||||
|
{/* 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
|
<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={{
|
style={{
|
||||||
color: STATUS_COLOR[status],
|
color: STATUS_COLOR[status],
|
||||||
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
|
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
|
||||||
@@ -30,6 +33,19 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
|||||||
{STATUS_LABEL[status]}
|
{STATUS_LABEL[status]}
|
||||||
</span>
|
</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 && (
|
{item.rating != null && (
|
||||||
<span
|
<span
|
||||||
className="absolute right-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
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)]">
|
<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 && (
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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 } });
|
||||||
|
});
|
||||||
|
|||||||
@@ -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