Compare commits
22
Commits
v0.6.0
..
58a1d177df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a1d177df | ||
|
|
d04248bec9 | ||
|
|
605afe556d | ||
|
|
39098cd201 | ||
|
|
fd1ab2918a | ||
|
|
40fb49fb21 | ||
|
|
5673055216 | ||
|
|
853d56f764 | ||
|
|
9d4e419cd1 | ||
|
|
a853b066b5 | ||
|
|
4ad5b244bc | ||
|
|
19f8003945 | ||
|
|
9ee8664233 | ||
|
|
cd9953c47c | ||
|
|
18c87e6b2e | ||
|
|
afa3d74248 | ||
|
|
b3eb946cfa | ||
|
|
29ef710b9b | ||
|
|
3db6241e9a | ||
|
|
d8239441aa | ||
|
|
96a42594a9 | ||
|
|
a54e625ca8 |
@@ -4,6 +4,7 @@ node_modules
|
||||
.env
|
||||
.env.local
|
||||
data
|
||||
backups
|
||||
docs/screenshots
|
||||
*.log
|
||||
test-results
|
||||
|
||||
@@ -27,6 +27,9 @@ AUTH_SECRET=bitte-durch-zufallswert-ersetzen
|
||||
# Ablage für Bild-Cache und sonstige Laufzeitdaten.
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# Port, auf dem Flimmer auf dem Host lauscht.
|
||||
APP_PORT=3000
|
||||
|
||||
# Zeitzone für Kalender, Statistiken und geplante Läufe
|
||||
TZ=Europe/Berlin
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Prüft jeden Push: Typen, Tests, Produktionsbuild.
|
||||
#
|
||||
# Die Tests brauchen eine echte PostgreSQL-Instanz — sie prüfen Abfragen und
|
||||
# Fortschrittsberechnungen gegen die Datenbank, nicht gegen Attrappen. Einen
|
||||
# TMDB-Schlüssel brauchen sie nicht: die TMDB-Antworten sind nachgebildet.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
pruefen:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: flimmer
|
||||
POSTGRES_PASSWORD: flimmer-ci
|
||||
POSTGRES_DB: flimmer
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U flimmer -d flimmer"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://flimmer:flimmer-ci@localhost:5432/flimmer?schema=public
|
||||
AUTH_SECRET: ci-geheimnis-0123456789abcdef0123456789abcdef
|
||||
APP_URL: http://localhost:3000
|
||||
TZ: Europe/Berlin
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Abhängigkeiten installieren
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Prisma-Client erzeugen
|
||||
run: npx prisma generate
|
||||
|
||||
- name: Schema einspielen
|
||||
run: npx prisma migrate deploy
|
||||
|
||||
- name: Typen prüfen
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
- name: Produktionsbuild
|
||||
run: npx next build
|
||||
@@ -0,0 +1,56 @@
|
||||
# Legt beim Pushen eines Versions-Tags automatisch das Release an.
|
||||
#
|
||||
# Die Notizen kommen aus releases/vX.Y.Z.md — derselben Datei, die auch von
|
||||
# Hand für ein Release taugt. So gibt es genau eine Quelle für den Text, und
|
||||
# der Ablauf funktioniert mit und ohne CI.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
veroeffentlichen:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Release-Notizen einsammeln
|
||||
id: notes
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
FILE="releases/${GITHUB_REF_NAME}.md"
|
||||
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "Es gibt keine $FILE — bitte anlegen, bevor der Tag gepusht wird." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Die Überschrift der Datei ist der Titel; alles darunter der Text.
|
||||
{
|
||||
echo "version=$VERSION"
|
||||
echo "body<<FLIMMER_EOF"
|
||||
tail -n +2 "$FILE"
|
||||
echo "FLIMMER_EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Release anlegen
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BODY: ${{ steps.notes.outputs.body }}
|
||||
VERSION: ${{ steps.notes.outputs.version }}
|
||||
run: |
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg tag "$GITHUB_REF_NAME" \
|
||||
--arg name "$VERSION" \
|
||||
--arg body "$BODY" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||
|
||||
curl -sSf -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
|
||||
@@ -11,6 +11,7 @@ next-env.d.ts
|
||||
|
||||
# Produktion / Laufzeit
|
||||
data/
|
||||
backups/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Umgebung — niemals committen!
|
||||
@@ -42,3 +43,4 @@ Thumbs.db
|
||||
/blob-report/
|
||||
.playwright/
|
||||
src/generated/
|
||||
.env.composetest
|
||||
|
||||
+89
-1
@@ -7,6 +7,92 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.7.1] — 2026-09-01
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
|
||||
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
|
||||
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
|
||||
wieder antwortet.
|
||||
|
||||
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
|
||||
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
|
||||
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
|
||||
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
|
||||
|
||||
Weitere Eigenheiten:
|
||||
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
|
||||
unterbleibt dann, statt sie zu überfahren.
|
||||
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
|
||||
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
|
||||
Healthcheck des Containers.
|
||||
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
|
||||
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
|
||||
|
||||
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
|
||||
Compose-Datei als Vorgabewert.
|
||||
|
||||
## [0.7.0] — 2026-09-01
|
||||
|
||||
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
|
||||
aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
|
||||
gestartet, und dabei kamen Fehler ans Licht, die nur der echte Containerstart
|
||||
zeigt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Eigene Sammlung**: Jeder Eintrag lässt sich als „hab ich" markieren, mit
|
||||
Angabe wo — Jellyfin, Plex, Emby, NAS, Blu-ray, DVD oder frei getippt.
|
||||
Bewusst getrennt vom Feld „wo schaue ich das": Streaming-Verfügbarkeit kommt
|
||||
und geht, der gekaufte Film auf dem Heimserver bleibt. Gerade die Titel, die
|
||||
nirgends mehr laufen, will man ja wiederfinden.
|
||||
- Abzeichen auf der Posterkachel und im Kopf der Detailseite
|
||||
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit
|
||||
— was man hat, ist die verlässlichere Antwort auf „wo kann ich das schauen"
|
||||
- Eigener Filter in der Bibliothek samt Zähler
|
||||
- In Export (CSV und JSON), CSV-Import und REST-API (`?sammlung=1` beim
|
||||
Lesen, `owned` und `ownedSource` beim Ändern)
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Der Container konnte seine Migrationen nicht einspielen.** Das Laufzeitimage
|
||||
bekam die Prisma-CLI, indem einzelne Ordner aus `node_modules` kopiert wurden
|
||||
— dabei fehlten transitive Abhängigkeiten von `@prisma/config` (`effect` und
|
||||
weitere). Die CLI wird jetzt in einer eigenen Build-Stufe vollständig
|
||||
installiert und liegt im Laufzeitimage unter `/node_modules`, wo sie den
|
||||
getracten Abhängigkeitsbaum der Anwendung nicht stört.
|
||||
|
||||
- **Der Build hing im Container am Schriftabruf.** `next/font/google` lädt
|
||||
Outfit beim Bauen von Google herunter — das macht den Build von einer
|
||||
Netzverbindung abhängig und blieb im Container hängen. Die variable Fassung
|
||||
liegt jetzt als Datei im Repository; weder beim Bauen noch zur Laufzeit geht
|
||||
eine Anfrage nach außen. Für eine selbst gehostete Anwendung ist das ohnehin
|
||||
das Richtige.
|
||||
- **npm blockierte Prismas Install-Skripte** (Voreinstellung seit npm 11),
|
||||
wodurch im Image die plattformpassende Engine fehlen konnte. Sie sind jetzt
|
||||
gezielt für `prisma` und `@prisma/engines` erlaubt statt pauschal.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Erscheinungsmeldung für Watchlist-Filme.** Der Schalter dafür stand schon im
|
||||
Profil, verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der
|
||||
letzten Tage statt eines Zustandswechsels — TMDB verschiebt Termine
|
||||
gelegentlich, und ein einmal verpasstes Datum soll die Meldung nicht für
|
||||
immer verhindern.
|
||||
- **Gitea-Actions-Workflows**: `ci.yml` prüft bei jedem Push Typen, Tests und
|
||||
Produktionsbuild gegen eine echte PostgreSQL-Instanz; `release.yml` legt beim
|
||||
Pushen eines Versions-Tags das Release mit den Notizen aus
|
||||
`releases/vX.Y.Z.md` an.
|
||||
- Diagrammfarben und ihre Prüfwerte in `docs/DESIGN.md` festgehalten; das
|
||||
öffentliche Profil in die Anleitung aufgenommen.
|
||||
|
||||
### Entfernt
|
||||
|
||||
- Die ungenutzte `images`-Konfiguration in `next.config.ts`. `next/image` kommt
|
||||
nirgends zum Einsatz — damit entfällt auch das sicherheitsrelevant aussehende
|
||||
`dangerouslyAllowSVG`, das ohnehin wirkungslos war.
|
||||
|
||||
## [0.6.0] — 2026-09-01
|
||||
|
||||
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
|
||||
@@ -261,7 +347,9 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
|
||||
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
||||
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
||||
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.6.0...HEAD
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.1...HEAD
|
||||
[0.7.1]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.1
|
||||
[0.7.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.0
|
||||
[0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
|
||||
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
|
||||
[0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
|
||||
|
||||
@@ -96,6 +96,9 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
||||
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
|
||||
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
|
||||
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`.
|
||||
Achtung: das Skript meldet sich **nicht** an — bei geschützten Seiten entsteht
|
||||
kommentarlos ein Bild der Anmeldemaske. Für angemeldete Ansichten den
|
||||
Login-Weg aus `scripts/screenshots.mjs` nachbauen.
|
||||
- Next hängt selbst einen Block an diese Datei an — der bleibt drin.
|
||||
|
||||
## Fortschritt
|
||||
@@ -119,6 +122,18 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
|
||||
das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok.
|
||||
- Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das
|
||||
Write-Werkzeug benutzen.
|
||||
- **Klickwege niemals gegen `npm run dev` prüfen.** Unter headless Chromium
|
||||
scheitert der HMR-Socket (`ERR_INVALID_HTTP_RESPONSE`), dadurch hydratisiert
|
||||
**keine** Seite und kein Knopf reagiert — ohne Seitenfehler, ohne
|
||||
fehlgeschlagene Anfrage, es passiert einfach nichts. Sieht aus wie ein Fehler
|
||||
im eigenen Code, ist aber die Umgebung. Stattdessen
|
||||
`npm run build` und `PORT=3012 npm start`, dann dagegen klicken.
|
||||
Formulare täuschen zusätzlich: die Anmeldung funktioniert auch ohne
|
||||
JavaScript, wirkt also intakt.
|
||||
- Vor `npm start` prüfen, ob der Port noch von einem früheren Lauf belegt ist —
|
||||
sonst startet der neue Server gar nicht und man testet den alten Bau.
|
||||
- Testdaten nach Klickproben wieder herstellen: die Proben schreiben in die
|
||||
geseedete Entwicklungsdatenbank.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
|
||||
@@ -91,6 +91,18 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
|
||||
---
|
||||
|
||||
## Nach der Abnahme (v0.7.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 7.1 | 20:25 | 20:50 | 25 min | Docker-Image zum ersten Mal wirklich gebaut und gestartet; zwei Fehler behoben (fehlende Prisma-Abhängigkeiten im Laufzeitimage, Schriftabruf beim Bauen), Gitea-Workflows, Erscheinungsmeldung nachgezogen |
|
||||
| 7.2 | 20:50 | 21:05 | 15 min | Compose-Stack von null verifiziert: Migration, erstes Konto wird Administrator, Verwaltung erreichbar |
|
||||
| 7.3 | 21:05 | 21:16 | 11 min | Rückfrage aus dem Betrieb umgesetzt: „hab ich" für die eigene Sammlung (Jellyfin, Plex, NAS, Blu-ray) — Schema, Logik, Oberfläche, Filter, Import/Export, API, Tests, Anleitung |
|
||||
|
||||
**Nacharbeiten gesamt: 51 min** (2026-09-01, 20:25 – 21:16)
|
||||
|
||||
---
|
||||
|
||||
## Gesamtübersicht
|
||||
|
||||
| Etappe | Version | Dauer |
|
||||
@@ -101,8 +113,9 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
| 4 — Universen | 0.4.0 | 14 min |
|
||||
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
|
||||
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
|
||||
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
|
||||
|
||||
**Gesamtentwicklungszeit: 2 h 16 min** (136 min, 2026-09-01, 18:09 – 20:25)
|
||||
**Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16)
|
||||
|
||||
### Womit die Zeit verging
|
||||
|
||||
@@ -111,7 +124,7 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
| ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) |
|
||||
| ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) |
|
||||
| ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) |
|
||||
| ~20 min | Tests — 49 Stück, die unterwegs sieben echte Fehler gefunden haben |
|
||||
| ~25 min | Tests — 52 Stück, die unterwegs acht echte Fehler gefunden haben |
|
||||
| ~15 min | Dokumentation, Screenshots, Changelog, Releases |
|
||||
| ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette |
|
||||
|
||||
@@ -124,3 +137,14 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel.
|
||||
6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build.
|
||||
7. Der SVG-Titel eines Diagramms brach die Hydration.
|
||||
8. Eine Testerwartung zur Reihenfolge war falsch — nicht der Code.
|
||||
|
||||
### Fehler, die erst der echte Containerstart zeigte
|
||||
|
||||
1. Dem Laufzeitimage fehlten transitive Abhängigkeiten der Prisma-CLI, die
|
||||
Migration lief nicht an. Die CLI wird jetzt in einer eigenen Build-Stufe
|
||||
vollständig installiert.
|
||||
2. npm blockiert seit Version 11 Install-Skripte; ohne Freigabe fehlte die
|
||||
plattformpassende Prisma-Engine.
|
||||
3. `next/font/google` lud die Schrift beim Bauen herunter und ließ den Build
|
||||
im Container hängen. Die Schrift liegt jetzt im Repository.
|
||||
|
||||
+22
-3
@@ -16,6 +16,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
# npm blockt Install-Skripte seit Version 11. Welche laufen dürfen, steht im
|
||||
# Feld "allowScripts" der package.json — für Prisma, damit die zur Plattform
|
||||
# passende Engine bereitgelegt wird.
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
# --- Build -------------------------------------------------------------------
|
||||
@@ -32,6 +35,18 @@ ENV NODE_ENV=production
|
||||
|
||||
RUN npx prisma generate && npx next build
|
||||
|
||||
# --- Prisma-CLI --------------------------------------------------------------
|
||||
# Eigene Stufe, damit die CLI samt ihrer transitiven Abhängigkeiten vollständig
|
||||
# ins Laufzeitimage kommt. Einzelne Pakete aus node_modules herauszupicken ging
|
||||
# schief: @prisma/config braucht weitere Module, die dabei fehlten.
|
||||
FROM base AS prisma-cli
|
||||
WORKDIR /cli
|
||||
# Die package.json liegt als Datei bei: nur mit dem Feld "allowScripts" lässt
|
||||
# npm die Install-Skripte von Prisma zu — sonst fehlt im Image die zur
|
||||
# Plattform passende Engine.
|
||||
COPY docker/prisma-cli-package.json ./package.json
|
||||
RUN npm install --omit=dev --no-audit --no-fund prisma@7.10.0
|
||||
|
||||
# --- Laufzeit ----------------------------------------------------------------
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
@@ -48,12 +63,16 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=flimmer:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=flimmer:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Prisma-CLI und Schema bleiben im Image, damit der Container beim Start seine
|
||||
# Schema und Migrationen bleiben im Image, damit der Container beim Start seine
|
||||
# eigenen Migrationen ausspielen kann (`prisma migrate deploy`).
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.mjs ./prisma.config.mjs
|
||||
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
# Die CLI liegt bewusst unter /node_modules statt in /app/node_modules: dort
|
||||
# stört sie den getracten Abhängigkeitsbaum der Anwendung nicht, und Node
|
||||
# findet sie beim Auflösen von "prisma/config" trotzdem — die Suche wandert
|
||||
# von /app aus nach oben.
|
||||
COPY --from=prisma-cli /cli/node_modules /node_modules
|
||||
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh \
|
||||
|
||||
@@ -33,6 +33,9 @@ bleiben auf deinem Server.
|
||||
Story-Chronologie, Einsteiger-Route) und Gesamtfortschritt.
|
||||
- **Metadaten von TMDB** — Suche, Poster, Staffeln und Episoden, offizielle
|
||||
Collections, Streaming-Verfügbarkeit in deiner Region.
|
||||
- **Eigene Sammlung** — markiere, was du schon hast, und wo: Jellyfin, Plex,
|
||||
NAS, Blu-ray. Gerade die Titel, die nirgends mehr streamen, findest du so
|
||||
wieder.
|
||||
- **Benachrichtigungen** — bei neuen Episoden und Releases, per E-Mail, ntfy,
|
||||
Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar.
|
||||
- **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen
|
||||
@@ -111,14 +114,31 @@ flimmer.example.com {
|
||||
|
||||
```bash
|
||||
cd flimmer
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
./update.sh
|
||||
```
|
||||
|
||||
Datenbankmigrationen laufen automatisch beim Start des Containers — ein eigener
|
||||
Handgriff ist nicht nötig. **Vor** einem Update ein Backup zu ziehen ist
|
||||
trotzdem eine gute Angewohnheit.
|
||||
Das Skript sichert zuerst die Datenbank, holt die Änderungen, baut das Image neu
|
||||
und wartet, bis die Instanz wieder antwortet. Schlägt etwas fehl, nennt es den
|
||||
konkreten Weg zurück — mit dem Befehl und der Sicherungsdatei, nicht bloß dem
|
||||
Rat, ein Backup zu haben.
|
||||
|
||||
```
|
||||
./update.sh --ohne-backup ohne Datenbanksicherung
|
||||
./update.sh --ohne-pull nur neu bauen und starten
|
||||
./update.sh --behalte 20 wie viele Sicherungen aufgehoben werden (Standard: 10)
|
||||
./update.sh --hilfe
|
||||
```
|
||||
|
||||
Die Sicherungen landen unter `backups/` und sind gzip-komprimierte `pg_dump`s.
|
||||
|
||||
Von Hand geht es genauso:
|
||||
|
||||
```bash
|
||||
git pull && docker compose build && docker compose up -d
|
||||
```
|
||||
|
||||
Datenbankmigrationen laufen in beiden Fällen automatisch beim Start des
|
||||
Containers — ein eigener Handgriff ist nicht nötig.
|
||||
|
||||
## Backup
|
||||
|
||||
@@ -206,6 +226,13 @@ docker/ Entrypoint des Containers
|
||||
docs/ Anleitung mit Screenshots, API, Design-Dokumentation
|
||||
```
|
||||
|
||||
### Automatisierung
|
||||
|
||||
Unter `.gitea/workflows/` liegen zwei Abläufe für Gitea Actions: `ci.yml`
|
||||
prüft bei jedem Push Typen, Tests und Produktionsbuild gegen eine echte
|
||||
PostgreSQL-Instanz, `release.yml` legt beim Pushen eines Versions-Tags das
|
||||
Release an — mit den Notizen aus `releases/vX.Y.Z.md`.
|
||||
|
||||
## Hintergrundlauf
|
||||
|
||||
Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Nur die Datenbank — die App läuft bei der Entwicklung direkt über `npm run dev`.
|
||||
# docker compose -f docker-compose.dev.yml up -d
|
||||
#
|
||||
# Eigener Projektname: sonst teilt sich dieser Stack den Namen mit der
|
||||
# Produktiv-Compose (beide leiten ihn sonst aus dem Verzeichnis ab), und ein
|
||||
# `docker compose down -v` dort nimmt die Entwicklungsdatenbank mit.
|
||||
name: flimmer-dev
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
|
||||
@@ -40,7 +40,7 @@ attempt();
|
||||
'
|
||||
|
||||
echo "Flimmer: spiele Migrationen ein ..."
|
||||
node node_modules/prisma/build/index.js migrate deploy
|
||||
node /node_modules/prisma/build/index.js migrate deploy
|
||||
|
||||
echo "Flimmer: starte Anwendung."
|
||||
exec "$@"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "flimmer-prisma-cli",
|
||||
"private": true,
|
||||
"description": "Nur zum Installieren der Prisma-CLI ins Laufzeitimage. npm blockt Install-Skripte seit Version 11; ohne die Freigabe hier fehlt im Container die plattformpassende Engine. Bei einem Prisma-Update auch die Version in package.json anpassen.",
|
||||
"allowScripts": {
|
||||
"prisma@7.10.0": true,
|
||||
"@prisma/engines@7.10.0": true
|
||||
}
|
||||
}
|
||||
+56
-12
@@ -17,18 +17,20 @@ die echten.
|
||||
2. [Die Übersicht](#die-übersicht)
|
||||
3. [Titel finden und aufnehmen](#titel-finden-und-aufnehmen)
|
||||
4. [Eine Serie verfolgen](#eine-serie-verfolgen)
|
||||
5. [Die Bibliothek](#die-bibliothek)
|
||||
6. [Universen](#universen)
|
||||
7. [Kalender](#kalender)
|
||||
8. [Statistik](#statistik)
|
||||
9. [Einstellungen](#einstellungen)
|
||||
10. [Benachrichtigungen](#benachrichtigungen)
|
||||
11. [Import und Export](#import-und-export)
|
||||
12. [API-Schlüssel](#api-schlüssel)
|
||||
13. [Papierkorb](#papierkorb)
|
||||
14. [Verwaltung](#verwaltung)
|
||||
15. [Auf dem Handy](#auf-dem-handy)
|
||||
16. [Helles Farbschema](#helles-farbschema)
|
||||
5. [Hab ich — die eigene Sammlung](#hab-ich--die-eigene-sammlung)
|
||||
6. [Die Bibliothek](#die-bibliothek)
|
||||
7. [Universen](#universen)
|
||||
8. [Kalender](#kalender)
|
||||
9. [Statistik](#statistik)
|
||||
10. [Einstellungen](#einstellungen)
|
||||
11. [Benachrichtigungen](#benachrichtigungen)
|
||||
12. [Import und Export](#import-und-export)
|
||||
13. [API-Schlüssel](#api-schlüssel)
|
||||
14. [Papierkorb](#papierkorb)
|
||||
15. [Verwaltung](#verwaltung)
|
||||
16. [Öffentliches Profil](#öffentliches-profil)
|
||||
17. [Auf dem Handy](#auf-dem-handy)
|
||||
18. [Helles Farbschema](#helles-farbschema)
|
||||
|
||||
---
|
||||
|
||||
@@ -114,6 +116,34 @@ Karte zeigt dann „2. Durchlauf".
|
||||
|
||||
---
|
||||
|
||||
## Hab ich — die eigene Sammlung
|
||||
|
||||

|
||||
|
||||
„Wo läuft das?" beantwortet nur die halbe Frage. Manche Filme laufen nirgends
|
||||
mehr — die hat man gekauft und liegen bei sich zu Hause, in Jellyfin, Plex,
|
||||
Emby, auf dem NAS oder als Scheibe im Regal.
|
||||
|
||||
Dafür gibt es das Häkchen **Hab ich** auf der Detailseite, rechts über dem Feld
|
||||
„wo schaue ich das". Daneben trägst du ein, *wo* der Titel liegt; das Feld
|
||||
schlägt die gängigen Möglichkeiten vor, nimmt aber auch alles andere an.
|
||||
|
||||
Was das bewirkt:
|
||||
|
||||
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit —
|
||||
was du hast, ist die verlässlichere Antwort.
|
||||
- Poster in der Bibliothek bekommen ein Abzeichen, und statt des
|
||||
Streamingdienstes steht dort deine Quelle.
|
||||
- In der Filterleiste gibt es einen eigenen Knopf **Hab ich** samt Zähler.
|
||||
- Export und Import führen die Angabe mit (Spalten `HabIch` und `Sammlung`),
|
||||
ebenso die REST-API.
|
||||
|
||||
Bewusst getrennt vom Feld „wo schaue ich das": Das eine sagt, wo ein Titel
|
||||
gerade läuft — das ändert sich ständig. Das andere sagt, was dir gehört, und
|
||||
das bleibt.
|
||||
|
||||
---
|
||||
|
||||
## Die Bibliothek
|
||||
|
||||

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

|
||||
|
||||
Wer mag, kann eine schlichte Seite unter `/u/<adresse>` freischalten — mit
|
||||
Schauzeit, Zahlen, zuletzt Gesehenem und den Listen „Die meiste Zeit" und
|
||||
„Bestbewertet".
|
||||
|
||||
Was dort **nicht** steht: Notizen, Watchlist, E-Mail-Adresse, Tags. Die Seite
|
||||
braucht zwei Freigaben — deine in den Einstellungen und die des Betreibers in
|
||||
der Verwaltung. Fehlt eine davon, ist die Adresse schlicht nicht erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## Auf dem Handy
|
||||
|
||||

|
||||
|
||||
+45
-8
@@ -57,8 +57,9 @@ GET /api/v1/bibliothek?status=WATCHING&typ=SERIES&genre=Drama&q=nord&limit=50&o
|
||||
POST /api/v1/bibliothek
|
||||
```
|
||||
|
||||
Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `limit`, `offset`.
|
||||
Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
|
||||
Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `sammlung`, `limit`,
|
||||
`offset`. Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
|
||||
`sammlung=1` liefert nur Titel, die in der eigenen Sammlung liegen.
|
||||
|
||||
Aufnehmen — entweder mit interner Kennung oder direkt mit der TMDB-Kennung:
|
||||
|
||||
@@ -75,7 +76,9 @@ DELETE /api/v1/bibliothek/{entryId}
|
||||
```
|
||||
|
||||
`GET` liefert den Eintrag samt `progress` und `nextEpisode`.
|
||||
`PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite`.
|
||||
`PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite`
|
||||
sowie `owned` (liegt der Titel in der eigenen Sammlung?) und `ownedSource`
|
||||
(wo genau — Jellyfin, Plex, Blu-ray …).
|
||||
`DELETE` legt den Eintrag in den Papierkorb — dort bleibt er 30 Tage.
|
||||
|
||||
### Sichtungen
|
||||
@@ -113,6 +116,19 @@ curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/jso
|
||||
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
|
||||
Markierung dieser Episoden im aktuellen Durchlauf.
|
||||
|
||||
Für Filme gibt es `{"action":"movie"}` als Gegenstück zum gleichnamigen POST;
|
||||
dafür braucht man die `watchIds` nicht zu kennen:
|
||||
|
||||
```bash
|
||||
curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||
-d '{"action":"movie"}' \
|
||||
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen
|
||||
```
|
||||
|
||||
Zurückgenommen wird dabei **nur der laufende Durchlauf**. Wer einen Film dreimal
|
||||
gesehen hat, verliert also nicht die Sichtungen der früheren Durchläufe — die
|
||||
zählen weiter in die Statistik.
|
||||
|
||||
### Suche
|
||||
|
||||
```http
|
||||
@@ -142,15 +158,28 @@ Liefert `totals`, `monthly`, `providers`, `genres` und `yearReview` auf einmal.
|
||||
### Universen
|
||||
|
||||
```http
|
||||
GET /api/v1/universen
|
||||
POST /api/v1/universen
|
||||
GET /api/v1/universen/{id}?reihenfolge={orderId}
|
||||
POST /api/v1/universen/{id}
|
||||
GET /api/v1/universen
|
||||
POST /api/v1/universen
|
||||
GET /api/v1/universen/{id}?reihenfolge={orderId}
|
||||
POST /api/v1/universen/{id}
|
||||
PATCH /api/v1/universen/{id}
|
||||
```
|
||||
|
||||
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
|
||||
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
|
||||
Erscheinungsreihenfolge.
|
||||
Standardreihenfolge des Universums — in `orders` trägt sie `isDefault`.
|
||||
|
||||
`PATCH` legt fest, welche das ist. Die Reihenfolge muss zu diesem Universum
|
||||
gehören, und es braucht Schreibrecht:
|
||||
|
||||
```bash
|
||||
curl -X PATCH -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
|
||||
-d '{"defaultOrderId": "…"}' \
|
||||
https://flimmer.example.com/api/v1/universen/$ID
|
||||
```
|
||||
|
||||
Je Universum trägt immer genau eine Reihenfolge den Standard. Wird die
|
||||
Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge.
|
||||
|
||||
### Export
|
||||
|
||||
@@ -161,6 +190,14 @@ GET /api/v1/export?format=csv
|
||||
|
||||
JSON enthält zusätzlich alle Sichtungen und Universen.
|
||||
|
||||
## Beispiel: was ich habe, aber noch nicht gesehen habe
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $KEY" \
|
||||
"$HOST/api/v1/bibliothek?sammlung=1&status=WATCHLIST" \
|
||||
| jq -r '.items[] | "\(.title) — liegt bei: \(.ownedSource // "ohne Angabe")"'
|
||||
```
|
||||
|
||||
## Beispiel: Serien mit offener Episode auflisten
|
||||
|
||||
```bash
|
||||
|
||||
+32
-3
@@ -103,6 +103,30 @@ denselben Variablennamen — keine Änderung an Komponenten.
|
||||
| `.fl-progress` | Fortschrittsbalken im Signaturverlauf |
|
||||
| `.fl-glimmer` | Mikro-Animation beim Abhaken einer Episode |
|
||||
|
||||
## Diagrammfarben
|
||||
|
||||
Die Signaturfarben sind für Diagrammflächen auf dunklem Grund zu hell. Für die
|
||||
beiden Serien der Statistik stehen deshalb abgedunkelte Varianten:
|
||||
|
||||
| Serie | Wert | Prüfung |
|
||||
|---|---|---|
|
||||
| Episoden | `#c47a18` | Helligkeitsband, Sättigung, Kontrast ≥ 3:1 — in **beiden** Modi |
|
||||
| Filme | `#d92d78` | dito |
|
||||
|
||||
Das schlechteste Paar erreicht ΔE 12,1 unter Deuteranopie und 20,6 bei
|
||||
normalem Sehen (Ziel: ≥ 8 bzw. ≥ 15). Beide Werte gelten auf hellem wie
|
||||
dunklem Grund, weshalb es nur **eine** Diagrammpalette gibt statt zweier.
|
||||
|
||||
Balken ohne Identitätsbedeutung (Genre-Verteilung) tragen einheitlich
|
||||
`#c47a18` — die Länge zeigt schon die Größe, die Farbe muss nichts
|
||||
wiederholen. Streamingdienste tragen ihre Markenfarbe, aber nur, wenn sie auf
|
||||
hellem *und* dunklem Grund mindestens 3:1 erreicht; sonst fällt der Balken auf
|
||||
den geprüften Standardton zurück.
|
||||
|
||||
Die Aussage hängt nie allein an der Farbe: Legende, Achsenwerte, ein Tooltip
|
||||
beim Überfahren und eine für Screenreader hinterlegte Tabelle tragen dieselbe
|
||||
Information.
|
||||
|
||||
## Bewegung
|
||||
|
||||
Ruhig und kurz: 200–420 ms, `cubic-bezier(0.22, 1, 0.36, 1)`. Keine harten
|
||||
@@ -111,9 +135,14 @@ verklingt. `prefers-reduced-motion: reduce` schaltet sämtliche Übergänge ab.
|
||||
|
||||
## Typografie
|
||||
|
||||
**Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Wird beim Bauen
|
||||
heruntergeladen und mit ausgeliefert; die laufende Instanz fragt nie bei Google
|
||||
an. Rückfallkette: Inter, dann System-Sans.
|
||||
**Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Die variable
|
||||
Fassung (Strichstärke 100–900) liegt als Datei im Repository
|
||||
(`public/fonts/`), aufgeteilt in Latin und Latin-Extended.
|
||||
|
||||
Bewusst nicht `next/font/google`: das lädt die Schrift beim Bauen herunter und
|
||||
macht den Build von einer Netzverbindung abhängig — im Container blieb er
|
||||
daran hängen. Mit der mitgelieferten Datei gibt es weder beim Bauen noch zur
|
||||
Laufzeit einen Abruf nach außen. Rückfallkette: Inter, dann System-Sans.
|
||||
|
||||
## Grundhaltung
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
+2
-6
@@ -14,12 +14,8 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
poweredByHeader: false,
|
||||
serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"],
|
||||
// Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über
|
||||
// next/image-Remote-Loader — deshalb hier keine remotePatterns nötig.
|
||||
images: {
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: "attachment",
|
||||
},
|
||||
// Bilder laufen über den eigenen Cache-Endpunkt (/api/image), nicht über
|
||||
// next/image — deshalb ist hier nichts zu konfigurieren.
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
Generated
+2
-14
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flimmer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
@@ -25,7 +25,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/bcryptjs": "3.0.0",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
@@ -1945,17 +1944,6 @@
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
|
||||
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
|
||||
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcryptjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||
"license": "MIT",
|
||||
@@ -36,7 +36,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/bcryptjs": "3.0.0",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
@@ -51,5 +50,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@7.10.0": true,
|
||||
"@prisma/engines@7.10.0": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LibraryEntry" ADD COLUMN "owned" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "ownedSource" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LibraryEntry_userId_owned_idx" ON "LibraryEntry"("userId", "owned");
|
||||
@@ -289,6 +289,13 @@ model LibraryEntry {
|
||||
/// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar
|
||||
whereToWatch String?
|
||||
|
||||
/// Liegt der Titel in der eigenen Sammlung? Unabhängig davon, ob ihn gerade
|
||||
/// ein Streamingdienst führt — gekaufte Filme laufen irgendwann nirgends mehr.
|
||||
owned Boolean @default(false)
|
||||
|
||||
/// Wo genau: „Jellyfin", „Blu-ray", „NAS", „Plex" …
|
||||
ownedSource String?
|
||||
|
||||
favorite Boolean @default(false)
|
||||
|
||||
/// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch …
|
||||
@@ -309,6 +316,7 @@ model LibraryEntry {
|
||||
@@unique([userId, titleId])
|
||||
@@index([userId, status])
|
||||
@@index([userId, updatedAt])
|
||||
@@index([userId, owned])
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -244,6 +244,9 @@ type EntryPlan = {
|
||||
startedDaysAgo?: number;
|
||||
/** Abstand zwischen zwei Episoden in Tagen. */
|
||||
spacingDays?: number;
|
||||
/** Liegt der Titel in der eigenen Sammlung? */
|
||||
owned?: boolean;
|
||||
ownedSource?: string;
|
||||
};
|
||||
|
||||
const ENTRY_PLANS: EntryPlan[] = [
|
||||
@@ -315,8 +318,8 @@ const ENTRY_PLANS: EntryPlan[] = [
|
||||
},
|
||||
{ key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] },
|
||||
|
||||
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340 },
|
||||
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320 },
|
||||
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340, owned: true, ownedSource: "Jellyfin" },
|
||||
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320, owned: true, ownedSource: "Jellyfin" },
|
||||
{ key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 },
|
||||
{ key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] },
|
||||
{ key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] },
|
||||
@@ -330,7 +333,7 @@ const ENTRY_PLANS: EntryPlan[] = [
|
||||
startedDaysAgo: 180,
|
||||
},
|
||||
{ key: "stille-bilder-2", status: "WATCHLIST" },
|
||||
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60 },
|
||||
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60, owned: true, ownedSource: "Blu-ray" },
|
||||
{ key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 },
|
||||
{ key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 },
|
||||
];
|
||||
@@ -355,6 +358,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
|
||||
rating: plan.rating ?? null,
|
||||
notes: plan.notes ?? null,
|
||||
favorite: plan.favorite ?? false,
|
||||
owned: plan.owned ?? false,
|
||||
ownedSource: plan.ownedSource ?? null,
|
||||
currentRun,
|
||||
startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null,
|
||||
finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null,
|
||||
@@ -364,6 +369,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
|
||||
rating: plan.rating ?? null,
|
||||
notes: plan.notes ?? null,
|
||||
favorite: plan.favorite ?? false,
|
||||
owned: plan.owned ?? false,
|
||||
ownedSource: plan.ownedSource ?? null,
|
||||
currentRun,
|
||||
},
|
||||
});
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,59 @@
|
||||
# Flimmer 0.7.0
|
||||
|
||||
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
|
||||
aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
|
||||
gestartet, und dabei kamen Fehler ans Licht, die nur der echte Containerstart
|
||||
zeigt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Eigene Sammlung**: Jeder Eintrag lässt sich als „hab ich" markieren, mit
|
||||
Angabe wo — Jellyfin, Plex, Emby, NAS, Blu-ray, DVD oder frei getippt.
|
||||
Bewusst getrennt vom Feld „wo schaue ich das": Streaming-Verfügbarkeit kommt
|
||||
und geht, der gekaufte Film auf dem Heimserver bleibt. Gerade die Titel, die
|
||||
nirgends mehr laufen, will man ja wiederfinden.
|
||||
- Abzeichen auf der Posterkachel und im Kopf der Detailseite
|
||||
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit
|
||||
— was man hat, ist die verlässlichere Antwort auf „wo kann ich das schauen"
|
||||
- Eigener Filter in der Bibliothek samt Zähler
|
||||
- In Export (CSV und JSON), CSV-Import und REST-API (`?sammlung=1` beim
|
||||
Lesen, `owned` und `ownedSource` beim Ändern)
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Der Container konnte seine Migrationen nicht einspielen.** Das Laufzeitimage
|
||||
bekam die Prisma-CLI, indem einzelne Ordner aus `node_modules` kopiert wurden
|
||||
— dabei fehlten transitive Abhängigkeiten von `@prisma/config` (`effect` und
|
||||
weitere). Die CLI wird jetzt in einer eigenen Build-Stufe vollständig
|
||||
installiert und liegt im Laufzeitimage unter `/node_modules`, wo sie den
|
||||
getracten Abhängigkeitsbaum der Anwendung nicht stört.
|
||||
|
||||
- **Der Build hing im Container am Schriftabruf.** `next/font/google` lädt
|
||||
Outfit beim Bauen von Google herunter — das macht den Build von einer
|
||||
Netzverbindung abhängig und blieb im Container hängen. Die variable Fassung
|
||||
liegt jetzt als Datei im Repository; weder beim Bauen noch zur Laufzeit geht
|
||||
eine Anfrage nach außen. Für eine selbst gehostete Anwendung ist das ohnehin
|
||||
das Richtige.
|
||||
- **npm blockierte Prismas Install-Skripte** (Voreinstellung seit npm 11),
|
||||
wodurch im Image die plattformpassende Engine fehlen konnte. Sie sind jetzt
|
||||
gezielt für `prisma` und `@prisma/engines` erlaubt statt pauschal.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Erscheinungsmeldung für Watchlist-Filme.** Der Schalter dafür stand schon im
|
||||
Profil, verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der
|
||||
letzten Tage statt eines Zustandswechsels — TMDB verschiebt Termine
|
||||
gelegentlich, und ein einmal verpasstes Datum soll die Meldung nicht für
|
||||
immer verhindern.
|
||||
- **Gitea-Actions-Workflows**: `ci.yml` prüft bei jedem Push Typen, Tests und
|
||||
Produktionsbuild gegen eine echte PostgreSQL-Instanz; `release.yml` legt beim
|
||||
Pushen eines Versions-Tags das Release mit den Notizen aus
|
||||
`releases/vX.Y.Z.md` an.
|
||||
- Diagrammfarben und ihre Prüfwerte in `docs/DESIGN.md` festgehalten; das
|
||||
öffentliche Profil in die Anleitung aufgenommen.
|
||||
|
||||
### Entfernt
|
||||
|
||||
- Die ungenutzte `images`-Konfiguration in `next.config.ts`. `next/image` kommt
|
||||
nirgends zum Einsatz — damit entfällt auch das sicherheitsrelevant aussehende
|
||||
`dangerouslyAllowSVG`, das ohnehin wirkungslos war.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Flimmer 0.7.1
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **`update.sh`** für den Betrieb unter Linux — ausführbar im Repository
|
||||
hinterlegt, also direkt nach dem Klonen startklar. Der Ablauf: Datenbank
|
||||
sichern, Änderungen holen, Image bauen, neu starten, warten bis die Instanz
|
||||
wieder antwortet.
|
||||
|
||||
Grundhaltung: erst sichern, dann anfassen. Schlägt die Sicherung fehl, bricht
|
||||
das Skript ab, statt weiterzumachen. Geht danach etwas schief, nennt es den
|
||||
**konkreten** Weg zurück — mit dem Commit, zu dem man zurückspringt, und der
|
||||
Datei, die man einspielt, nicht bloß dem Rat, ein Backup zu haben.
|
||||
|
||||
Weitere Eigenheiten:
|
||||
- Lokale Änderungen im Arbeitsverzeichnis werden erkannt; `git pull`
|
||||
unterbleibt dann, statt sie zu überfahren.
|
||||
- Erreichbarkeit wird über den veröffentlichten Port geprüft und, falls der
|
||||
nicht nach außen geht (Reverse Proxy im selben Docker-Netz), über den
|
||||
Healthcheck des Containers.
|
||||
- Alte Sicherungen werden aufgeräumt, die letzten zehn bleiben (`--behalte N`).
|
||||
- Kommt mit `docker compose` und dem alten `docker-compose` zurecht.
|
||||
|
||||
- `APP_PORT` in der `.env.example` — der Port stand bisher nur in der
|
||||
Compose-Datei als Vorgabewert.
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
startRewatch,
|
||||
undoWatches,
|
||||
unmarkEpisodes,
|
||||
unmarkMovie,
|
||||
unmarkSeason,
|
||||
updateEntryDetails,
|
||||
watchNextEpisode,
|
||||
@@ -114,6 +115,8 @@ const detailsSchema = z.object({
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
whereToWatch: z.string().max(120).nullable().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
owned: z.boolean().optional(),
|
||||
ownedSource: z.string().max(80).nullable().optional(),
|
||||
});
|
||||
|
||||
export async function updateEntryAction(entryId: string, data: unknown) {
|
||||
@@ -173,6 +176,13 @@ export async function markMovieAction(entryId: string) {
|
||||
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
|
||||
}
|
||||
|
||||
export async function unmarkMovieAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
await unmarkMovie(userId, cuid.parse(entryId));
|
||||
return { message: "Markierung entfernt" };
|
||||
}, PATHS);
|
||||
}
|
||||
|
||||
export async function undoAction(entryId: string, watchIds: string[]) {
|
||||
return run(async (userId) => {
|
||||
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
removeTitle,
|
||||
renameOrder,
|
||||
renameUniverse,
|
||||
setDefaultOrder,
|
||||
setOrderPositions,
|
||||
shareUniverse,
|
||||
trashUniverse,
|
||||
@@ -124,6 +125,13 @@ export async function deleteOrderAction(orderId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function setDefaultOrderAction(orderId: string) {
|
||||
return run(async (userId) => {
|
||||
await setDefaultOrder(userId, id.parse(orderId));
|
||||
return { message: "Standardreihenfolge gesetzt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderAction(orderId: string, itemIds: string[]) {
|
||||
return run(async (userId) => {
|
||||
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
|
||||
|
||||
@@ -36,6 +36,8 @@ export const GET = withApi(async (_request, user, context) => {
|
||||
rating: entry.rating,
|
||||
notes: entry.notes,
|
||||
whereToWatch: entry.whereToWatch,
|
||||
owned: entry.owned,
|
||||
ownedSource: entry.ownedSource,
|
||||
favorite: entry.favorite,
|
||||
currentRun: entry.currentRun,
|
||||
tags: entry.tags.map((link) => link.tag.name),
|
||||
@@ -58,6 +60,8 @@ const patchSchema = z.object({
|
||||
notes: z.string().max(5000).nullable().optional(),
|
||||
whereToWatch: z.string().max(120).nullable().optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
owned: z.boolean().optional(),
|
||||
ownedSource: z.string().max(80).nullable().optional(),
|
||||
});
|
||||
|
||||
/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
markUpTo,
|
||||
undoWatches,
|
||||
unmarkEpisodes,
|
||||
unmarkMovie,
|
||||
watchNextEpisode,
|
||||
} from "@/lib/library";
|
||||
|
||||
@@ -75,9 +76,17 @@ export const POST = withApi(async (request, user, context) => {
|
||||
const deleteSchema = z.union([
|
||||
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||
z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
|
||||
z.object({ action: z.literal("movie") }),
|
||||
]);
|
||||
|
||||
/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */
|
||||
/**
|
||||
* DELETE — Sichtungen zurücknehmen.
|
||||
*
|
||||
* Drei Wege: gezielt über `watchIds` (die Antwort eines POST), über
|
||||
* `episodeIds`, oder mit `{"action":"movie"}` als Gegenstück zum
|
||||
* gleichnamigen POST — dafür braucht man die `watchIds` nicht zu kennen.
|
||||
* Zurückgenommen wird immer nur der laufende Durchlauf.
|
||||
*/
|
||||
export const DELETE = withApi(async (request, user, context) => {
|
||||
const { entryId } = await context.params;
|
||||
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
|
||||
@@ -86,8 +95,10 @@ export const DELETE = withApi(async (request, user, context) => {
|
||||
|
||||
if ("watchIds" in body) {
|
||||
await undoWatches(user.id, entryId, body.watchIds);
|
||||
} else {
|
||||
} else if ("episodeIds" in body) {
|
||||
await unmarkEpisodes(user.id, entryId, body.episodeIds);
|
||||
} else {
|
||||
await unmarkMovie(user.id, entryId);
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export const GET = withApi(async (request, user) => {
|
||||
genre: url.searchParams.get("genre") ?? undefined,
|
||||
tag: url.searchParams.get("tag") ?? undefined,
|
||||
provider: url.searchParams.get("dienst") ?? undefined,
|
||||
owned: url.searchParams.get("sammlung") === "1" ? true : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -73,6 +73,12 @@ export function GET() {
|
||||
{ name: "genre", in: "query", schema: { type: "string" } },
|
||||
{ name: "tag", in: "query", schema: { type: "string" } },
|
||||
{ name: "dienst", in: "query", schema: { type: "string" } },
|
||||
{
|
||||
name: "sammlung",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description: "Nur Titel, die in der eigenen Sammlung liegen.",
|
||||
},
|
||||
{ name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } },
|
||||
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } },
|
||||
],
|
||||
@@ -123,6 +129,15 @@ export function GET() {
|
||||
notes: { type: "string", nullable: true },
|
||||
whereToWatch: { type: "string", nullable: true },
|
||||
favorite: { type: "boolean" },
|
||||
owned: {
|
||||
type: "boolean",
|
||||
description: "Liegt der Titel in der eigenen Sammlung?",
|
||||
},
|
||||
ownedSource: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description: "Wo genau — Jellyfin, Plex, Blu-ray …",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -195,6 +210,11 @@ export function GET() {
|
||||
},
|
||||
delete: {
|
||||
summary: "Sichtungen zurücknehmen",
|
||||
description:
|
||||
"Gezielt über watchIds, über episodeIds, oder mit action=movie als Gegenstück " +
|
||||
"zum gleichnamigen POST — dafür braucht man die watchIds nicht zu kennen. " +
|
||||
"Zurückgenommen wird immer nur der laufende Durchlauf; frühere Durchläufe " +
|
||||
"bleiben für die Statistik erhalten.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -211,6 +231,11 @@ export function GET() {
|
||||
properties: { episodeIds: { type: "array", items: { type: "string" } } },
|
||||
required: ["episodeIds"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { action: { const: "movie" } },
|
||||
required: ["action"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -272,6 +297,9 @@ export function GET() {
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
||||
get: {
|
||||
summary: "Universum mit Reihenfolge und Fortschritt",
|
||||
description:
|
||||
"Ohne 'reihenfolge' gilt die Standardreihenfolge des Universums — sie trägt in " +
|
||||
"'orders' das Kennzeichen 'isDefault'.",
|
||||
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
|
||||
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
|
||||
},
|
||||
@@ -291,6 +319,33 @@ export function GET() {
|
||||
},
|
||||
responses: { "200": { description: "Aufgenommen" } },
|
||||
},
|
||||
patch: {
|
||||
summary: "Standardreihenfolge festlegen",
|
||||
description:
|
||||
"Bestimmt, welche Reihenfolge beim Öffnen erscheint. Braucht Schreibrecht; je " +
|
||||
"Universum trägt immer genau eine Reihenfolge den Standard.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
defaultOrderId: {
|
||||
type: "string",
|
||||
description: "Muss eine Reihenfolge dieses Universums sein.",
|
||||
},
|
||||
},
|
||||
required: ["defaultOrderId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": { description: "Gesetzt" },
|
||||
"404": { description: "Die Reihenfolge gehört nicht zu diesem Universum" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/export": {
|
||||
get: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { apiError, readJson, withApi } from "@/lib/api";
|
||||
import { addTitle, getUniverse } from "@/lib/universes";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { addTitle, getUniverse, setDefaultOrder } from "@/lib/universes";
|
||||
|
||||
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
|
||||
export const GET = withApi(async (request, user, context) => {
|
||||
@@ -25,3 +26,25 @@ export const POST = withApi(async (request, user, context) => {
|
||||
await addTitle(user.id, id, body.titleId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const patchSchema = z.object({ defaultOrderId: z.string().min(1) });
|
||||
|
||||
/** PATCH /api/v1/universen/{id} — festlegen, welche Reihenfolge beim Öffnen erscheint. */
|
||||
export const PATCH = withApi(async (request, user, context) => {
|
||||
const { id } = await context.params;
|
||||
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
|
||||
|
||||
const body = await readJson(request, patchSchema);
|
||||
|
||||
// Die Reihenfolge muss zu genau diesem Universum gehören. Ohne diese Prüfung
|
||||
// setzte ein PATCH auf Universum A den Standard in Universum B — die
|
||||
// Domänenschicht leitet das Universum ja aus der Reihenfolge ab.
|
||||
const order = await prisma.universeOrder.findFirst({
|
||||
where: { id: body.defaultOrderId, universeId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!order) return apiError("Diese Reihenfolge gehört nicht zu diesem Universum.", 404);
|
||||
|
||||
await setDefaultOrder(user.id, order.id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
+36
-30
@@ -6,6 +6,7 @@ import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type Libr
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { LibraryCard } from "@/components/library/LibraryCard";
|
||||
import { LibraryFilters } from "@/components/library/LibraryFilters";
|
||||
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
|
||||
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
|
||||
|
||||
export const metadata: Metadata = { title: "Bibliothek" };
|
||||
@@ -29,6 +30,7 @@ export default async function LibraryPage({
|
||||
genre: params.genre || undefined,
|
||||
tag: params.tag || undefined,
|
||||
provider: params.dienst || undefined,
|
||||
owned: params.sammlung === "1" ? true : undefined,
|
||||
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
|
||||
};
|
||||
|
||||
@@ -55,39 +57,43 @@ export default async function LibraryPage({
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
||||
<SearchPendingProvider>
|
||||
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
|
||||
|
||||
<div className="mt-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="text-[var(--fl-muted)]">
|
||||
{facets.counts.ALLE === 0
|
||||
? "Deine Bibliothek ist noch leer."
|
||||
: hasFilters
|
||||
? "Zu dieser Auswahl passt nichts."
|
||||
: "Hier ist nichts."}
|
||||
</p>
|
||||
{facets.counts.ALLE === 0 && (
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary mt-4">
|
||||
Ersten Titel suchen
|
||||
</Link>
|
||||
<SearchResults>
|
||||
<div className="mt-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="text-[var(--fl-muted)]">
|
||||
{facets.counts.ALLE === 0
|
||||
? "Deine Bibliothek ist noch leer."
|
||||
: hasFilters
|
||||
? "Zu dieser Auswahl passt nichts."
|
||||
: "Hier ist nichts."}
|
||||
</p>
|
||||
{facets.counts.ALLE === 0 && (
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary mt-4">
|
||||
Ersten Titel suchen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-[var(--fl-faint)]">
|
||||
{items.length} {items.length === 1 ? "Titel" : "Titel"}
|
||||
</p>
|
||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{items.map((item) => (
|
||||
<li key={item.entryId}>
|
||||
<LibraryCard item={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-[var(--fl-faint)]">
|
||||
{items.length} {items.length === 1 ? "Titel" : "Titel"}
|
||||
</p>
|
||||
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{items.map((item) => (
|
||||
<li key={item.entryId}>
|
||||
<LibraryCard item={item} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SearchResults>
|
||||
</SearchPendingProvider>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-5
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Outfit } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import { BrandDefs } from "@/components/brand/FlimmerLogo";
|
||||
import { ToastProvider } from "@/components/Toasts";
|
||||
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
|
||||
@@ -7,13 +7,24 @@ import { env } from "@/lib/env";
|
||||
import "./globals.css";
|
||||
|
||||
/**
|
||||
* Outfit wird beim Bauen heruntergeladen und mit ausgeliefert — die laufende
|
||||
* Instanz fragt also niemals bei Google an.
|
||||
* Outfit liegt als Datei im Repository.
|
||||
*
|
||||
* next/font/google würde die Schrift beim Bauen herunterladen — das macht den
|
||||
* Build von einer Netzverbindung abhängig und ließ ihn im Container hängen.
|
||||
* Für eine selbst gehostete Anwendung ist die mitgelieferte Datei ohnehin das
|
||||
* Richtige: kein Abruf beim Bauen, keiner zur Laufzeit.
|
||||
*
|
||||
* Es ist die variable Fassung (Strichstärke 100–900), aufgeteilt in Latin und
|
||||
* Latin-Extended — der Browser lädt nur, was die Seite wirklich braucht.
|
||||
*/
|
||||
const outfit = Outfit({
|
||||
subsets: ["latin"],
|
||||
const outfit = localFont({
|
||||
src: [
|
||||
{ path: "../../public/fonts/outfit-latin.woff2", weight: "100 900", style: "normal" },
|
||||
{ path: "../../public/fonts/outfit-latin-ext.woff2", weight: "100 900", style: "normal" },
|
||||
],
|
||||
display: "swap",
|
||||
variable: "--font-outfit",
|
||||
fallback: ["Inter", "Segoe UI", "system-ui", "sans-serif"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
+25
-20
@@ -7,6 +7,7 @@ import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { SearchField } from "@/components/SearchField";
|
||||
import { SearchPendingProvider, SearchResults } from "@/components/SearchPending";
|
||||
|
||||
export const metadata: Metadata = { title: "Suchen" };
|
||||
|
||||
@@ -54,27 +55,31 @@ export default async function SearchPage({
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
||||
<SearchPendingProvider>
|
||||
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
||||
|
||||
<p className="mt-3 text-sm text-[var(--fl-faint)]">
|
||||
Nichts dabei?{" "}
|
||||
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
|
||||
Titel selbst anlegen
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-[var(--fl-faint)]">
|
||||
Nichts dabei?{" "}
|
||||
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
|
||||
Titel selbst anlegen
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="mt-8">
|
||||
{!env.tmdb.isConfigured ? (
|
||||
<Notice tone="warning">
|
||||
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "}
|
||||
<code>.env</code> ein und starte die Instanz neu.
|
||||
</Notice>
|
||||
) : query.length < 2 ? (
|
||||
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
|
||||
) : (
|
||||
<Results query={query} filter={filter} />
|
||||
)}
|
||||
</div>
|
||||
<SearchResults>
|
||||
<div className="mt-8">
|
||||
{!env.tmdb.isConfigured ? (
|
||||
<Notice tone="warning">
|
||||
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in
|
||||
der <code>.env</code> ein und starte die Instanz neu.
|
||||
</Notice>
|
||||
) : query.length < 2 ? (
|
||||
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
|
||||
) : (
|
||||
<Results query={query} filter={filter} />
|
||||
)}
|
||||
</div>
|
||||
</SearchResults>
|
||||
</SearchPendingProvider>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +124,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
|
||||
>
|
||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="line-clamp-2 text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
|
||||
<p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
|
||||
@@ -120,6 +120,19 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
{year ? ` · ${year}` : ""}
|
||||
{title.status ? ` · ${title.status}` : ""}
|
||||
</p>
|
||||
{entry?.owned && (
|
||||
<span
|
||||
className="rounded-full px-2.5 py-0.5 text-xs font-medium text-[var(--fl-on-accent)]"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
title={
|
||||
entry.ownedSource
|
||||
? `In deiner Sammlung: ${entry.ownedSource}`
|
||||
: "In deiner Sammlung"
|
||||
}
|
||||
>
|
||||
Hab ich{entry.ownedSource ? ` · ${entry.ownedSource}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{entry && (
|
||||
<span
|
||||
className="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
@@ -225,6 +238,7 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
|
||||
) : (
|
||||
<div className="fl-card p-4">
|
||||
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
</div>
|
||||
)}
|
||||
@@ -245,6 +259,8 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
whereToWatch={entry.whereToWatch}
|
||||
tags={entry.tags.map((link) => link.tag.name)}
|
||||
suggestion={suggestion}
|
||||
owned={entry.owned}
|
||||
ownedSource={entry.ownedSource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -253,6 +269,7 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Wo läuft das?
|
||||
</h2>
|
||||
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
|
||||
<ProviderList groups={providerGroups} region={user.region} />
|
||||
</div>
|
||||
)}
|
||||
@@ -299,6 +316,34 @@ export default async function TitlePage({ params }: { params: Params }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinweis auf die eigene Sammlung — steht bewusst **vor** der
|
||||
* Streaming-Verfügbarkeit: was man hat, ist die verlässlichere Antwort auf
|
||||
* „wo kann ich das schauen".
|
||||
*/
|
||||
function OwnedNotice({ owned, source }: { owned: boolean; source: string | null }) {
|
||||
if (!owned) return null;
|
||||
|
||||
return (
|
||||
<p
|
||||
className="mb-3 flex items-center gap-2 rounded-xl border px-3 py-2 text-sm"
|
||||
style={{
|
||||
borderColor: "color-mix(in oklab, var(--fl-success) 45%, transparent)",
|
||||
backgroundColor: "color-mix(in oklab, var(--fl-success) 10%, transparent)",
|
||||
color: "var(--fl-success)",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
|
||||
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span>
|
||||
In deiner Sammlung
|
||||
{source ? <span className="font-medium"> · {source}</span> : null}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
|
||||
|
||||
/**
|
||||
* Suchfeld mit Typfilter.
|
||||
@@ -26,6 +27,11 @@ export function SearchField({
|
||||
const [pending, startTransition] = useTransition();
|
||||
const first = useRef(true);
|
||||
|
||||
const reportPending = useReportSearchPending();
|
||||
useEffect(() => {
|
||||
reportPending(pending);
|
||||
}, [pending, reportPending]);
|
||||
|
||||
useEffect(() => {
|
||||
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
|
||||
// gerade geladene URL durch eine identische.
|
||||
@@ -41,7 +47,7 @@ export function SearchField({
|
||||
startTransition(() => {
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
}, 350);
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, filter, pathname, router]);
|
||||
@@ -68,14 +74,7 @@ export function SearchField({
|
||||
autoFocus
|
||||
className="fl-input pl-10 text-base"
|
||||
/>
|
||||
{pending && (
|
||||
<span
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
|
||||
role="status"
|
||||
>
|
||||
sucht …
|
||||
</span>
|
||||
)}
|
||||
<SearchSpinner show={pending} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/**
|
||||
* Rückmeldung beim Tippen — geteilt von der Suche und der Bibliothek.
|
||||
*
|
||||
* Beide Filterleisten wissen über `useTransition()` längst, dass eine neue
|
||||
* Antwort unterwegs ist. Die Trefferliste weiß es nicht: sie ist eine
|
||||
* Server-Komponente und steht als `children` daneben. Diese schmale Klammer
|
||||
* reicht das eine Bit vom Filter zur Liste durch.
|
||||
*
|
||||
* Bewusst zwei Kontexte: der Setter behält so über alle Renderdurchgänge
|
||||
* dieselbe Identität und taugt als Abhängigkeit eines Effekts.
|
||||
*/
|
||||
|
||||
const PendingContext = createContext(false);
|
||||
const SetPendingContext = createContext<(value: boolean) => void>(() => {});
|
||||
|
||||
export function SearchPendingProvider({ children }: { children: ReactNode }) {
|
||||
const [pending, setPending] = useState(false);
|
||||
return (
|
||||
<SetPendingContext.Provider value={setPending}>
|
||||
<PendingContext.Provider value={pending}>{children}</PendingContext.Provider>
|
||||
</SetPendingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Für die Filterleiste: meldet, ob gerade gesucht wird. */
|
||||
export function useReportSearchPending() {
|
||||
return useContext(SetPendingContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dämpft die Treffer, solange die neue Antwort unterwegs ist.
|
||||
*
|
||||
* Die alten Treffer bleiben stehen — sie verschwinden zu lassen wäre ein
|
||||
* Rückschritt, weil die Liste dann bei jedem Tastendruck springt. Gedämpft
|
||||
* und nicht anklickbar ist aber sichtbar „nicht mehr aktuell".
|
||||
*/
|
||||
export function SearchResults({ children }: { children: ReactNode }) {
|
||||
const pending = useContext(PendingContext);
|
||||
return (
|
||||
<div
|
||||
aria-busy={pending}
|
||||
className={clsx(
|
||||
"transition-opacity duration-200 motion-reduce:transition-none",
|
||||
pending && "pointer-events-none opacity-45",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kleiner Hinweis neben dem Eingabefeld, solange gesucht wird. */
|
||||
export function SearchSpinner({ show }: { show: boolean }) {
|
||||
if (!show) return null;
|
||||
return (
|
||||
<span
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
|
||||
role="status"
|
||||
>
|
||||
sucht …
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { setTagsAction, updateEntryAction } from "@/app/actions/library";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
|
||||
@@ -17,6 +18,8 @@ export function EntryNotes({
|
||||
whereToWatch,
|
||||
tags,
|
||||
suggestion,
|
||||
owned,
|
||||
ownedSource,
|
||||
}: {
|
||||
entryId: string;
|
||||
notes: string | null;
|
||||
@@ -24,9 +27,12 @@ export function EntryNotes({
|
||||
tags: string[];
|
||||
/** Vorschlag aus der Streaming-Verfügbarkeit. */
|
||||
suggestion: string | null;
|
||||
owned: boolean;
|
||||
ownedSource: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<OwnedEditor entryId={entryId} owned={owned} source={ownedSource} />
|
||||
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
|
||||
<TagEditor entryId={entryId} tags={tags} />
|
||||
<Notes entryId={entryId} value={notes} />
|
||||
@@ -34,6 +40,94 @@ export function EntryNotes({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* „Hab ich" — der Titel liegt in der eigenen Sammlung.
|
||||
*
|
||||
* Bewusst getrennt von „wo schaue ich das": Streaming-Verfügbarkeit kommt und
|
||||
* geht, der gekaufte Film auf dem Heimserver bleibt. Genau die Titel, die
|
||||
* nirgends mehr laufen, will man ja wiederfinden.
|
||||
*/
|
||||
function OwnedEditor({
|
||||
entryId,
|
||||
owned,
|
||||
source,
|
||||
}: {
|
||||
entryId: string;
|
||||
owned: boolean;
|
||||
source: string | null;
|
||||
}) {
|
||||
const save = useAutosave(entryId);
|
||||
const [isOwned, setIsOwned] = useState(owned);
|
||||
const [text, setText] = useState(source ?? "");
|
||||
const initial = useRef(source ?? "");
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-[var(--fl-border)] p-3">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOwned}
|
||||
onChange={(event) => {
|
||||
setIsOwned(event.target.checked);
|
||||
save({ owned: event.target.checked });
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
"mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-all duration-200",
|
||||
isOwned ? "border-transparent" : "border-[var(--fl-border-strong)]",
|
||||
)}
|
||||
style={isOwned ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
{isOwned && (
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="var(--fl-on-accent)" strokeWidth="3.5">
|
||||
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">Hab ich</span>
|
||||
<span className="block text-xs text-[var(--fl-faint)]">
|
||||
Liegt in der eigenen Sammlung — unabhängig davon, ob es gerade irgendwo läuft.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{isOwned && (
|
||||
<div className="pl-8">
|
||||
<label htmlFor="ownedSource" className="sr-only">
|
||||
Wo liegt der Titel?
|
||||
</label>
|
||||
<input
|
||||
id="ownedSource"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onBlur={() => {
|
||||
if (text === initial.current) return;
|
||||
initial.current = text;
|
||||
save({ ownedSource: text });
|
||||
}}
|
||||
list="fl-owned-quellen"
|
||||
placeholder="Jellyfin, Plex, NAS, Blu-ray …"
|
||||
className="fl-input py-1.5 text-sm"
|
||||
/>
|
||||
<datalist id="fl-owned-quellen">
|
||||
<option value="Jellyfin" />
|
||||
<option value="Plex" />
|
||||
<option value="Emby" />
|
||||
<option value="NAS" />
|
||||
<option value="Blu-ray" />
|
||||
<option value="DVD" />
|
||||
<option value="Gekauft (digital)" />
|
||||
</datalist>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAutosave(entryId: string) {
|
||||
const { report } = useToasts();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -19,15 +19,31 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
<div className="relative">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||
|
||||
<span
|
||||
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
|
||||
style={{
|
||||
color: STATUS_COLOR[status],
|
||||
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[status]}
|
||||
{/* Status und Sammlung teilen sich die obere Zeile — unten sitzt bei
|
||||
den Postern oft der Titel, dort würden sie ihn verdecken. */}
|
||||
<span className="absolute left-2 top-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
|
||||
style={{
|
||||
color: STATUS_COLOR[status],
|
||||
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
|
||||
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
|
||||
{item.owned && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
title={
|
||||
item.ownedSource ? `In deiner Sammlung: ${item.ownedSource}` : "In deiner Sammlung"
|
||||
}
|
||||
>
|
||||
Hab ich
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{item.rating != null && (
|
||||
@@ -47,24 +63,39 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="line-clamp-2 text-sm font-medium leading-snug">{item.title}</p>
|
||||
<p className="line-clamp-2 min-h-[2lh] text-sm font-medium leading-snug">{item.title}</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
{item.type === "SERIES" ? "Serie" : "Film"}
|
||||
{item.year ? ` · ${item.year}` : ""}
|
||||
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""}
|
||||
{item.owned && item.ownedSource
|
||||
? ` · ${item.ownedSource}`
|
||||
: item.whereToWatch
|
||||
? ` · ${item.whereToWatch}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{showProgress && (
|
||||
<div className="pt-0.5">
|
||||
<ProgressBar
|
||||
percent={item.percent}
|
||||
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--fl-faint)]">
|
||||
{item.watched}/{item.total} Episoden
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-0.5">
|
||||
{showProgress ? (
|
||||
<>
|
||||
<ProgressBar
|
||||
percent={item.percent}
|
||||
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--fl-faint)]">
|
||||
{item.watched}/{item.total} Episoden
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
// Filme und Watchlist-Einträge haben keinen Balken. Der Platz wird
|
||||
// trotzdem freigehalten — sonst fallen sie kürzer aus als Serien und
|
||||
// das Raster franst reihenweise aus. Der Nachbau statt einer festen
|
||||
// Pixelhöhe hält sich von selbst nach, wenn sich der Balken ändert.
|
||||
<div className="invisible" aria-hidden>
|
||||
<div className="fl-progress" />
|
||||
<p className="mt-1 text-xs"> </p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
|
||||
import type { LibraryFacets } from "@/lib/queries";
|
||||
import { SearchSpinner, useReportSearchPending } from "@/components/SearchPending";
|
||||
|
||||
/**
|
||||
* Filterleiste der Bibliothek.
|
||||
@@ -26,11 +27,19 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||
const first = useRef(true);
|
||||
|
||||
// Der Trefferliste mitteilen, dass eine neue Antwort unterwegs ist — ohne
|
||||
// das Signal blieben die alten Treffer stumm stehen und es sah aus, als
|
||||
// passiere nichts.
|
||||
const reportPending = useReportSearchPending();
|
||||
useEffect(() => {
|
||||
reportPending(pending);
|
||||
}, [pending, reportPending]);
|
||||
|
||||
function apply(changes: Record<string, string | null>) {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
@@ -48,7 +57,7 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
||||
first.current = false;
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => apply({ q: query.trim() || null }), 300);
|
||||
const timer = setTimeout(() => apply({ q: query.trim() || null }), 250);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query]);
|
||||
@@ -56,18 +65,23 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
||||
const status = params.get("status");
|
||||
const type = params.get("typ");
|
||||
const sort = params.get("sortierung") ?? "aktualisiert";
|
||||
const activeCount = ["genre", "tag", "dienst", "typ"].filter((key) => params.get(key)).length;
|
||||
const activeCount = ["genre", "tag", "dienst", "typ", "sammlung"].filter((key) =>
|
||||
params.get(key),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="In der Bibliothek suchen …"
|
||||
aria-label="Bibliothek durchsuchen"
|
||||
className="fl-input"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="In der Bibliothek suchen …"
|
||||
aria-label="Bibliothek durchsuchen"
|
||||
className="fl-input"
|
||||
/>
|
||||
<SearchSpinner show={pending} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern">
|
||||
<Chip active={!status} onClick={() => apply({ status: null })}>
|
||||
@@ -78,6 +92,15 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
|
||||
{STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} />
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{facets.owned > 0 && (
|
||||
<Chip
|
||||
active={params.get("sammlung") === "1"}
|
||||
onClick={() => apply({ sammlung: params.get("sammlung") === "1" ? null : "1" })}
|
||||
>
|
||||
Hab ich <Count value={facets.owned} />
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markMovieAction, undoAction, watchNextAction } from "@/app/actions/library";
|
||||
import {
|
||||
markMovieAction,
|
||||
undoAction,
|
||||
unmarkMovieAction,
|
||||
watchNextAction,
|
||||
} from "@/app/actions/library";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
|
||||
|
||||
@@ -93,14 +98,31 @@ export function WatchMovieButton({
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
// Im Zustand „gesehen" bleibt die grüne Plakette stehen, ist aber klickbar —
|
||||
// so wie sich Episodenhäkchen auch wieder abwählen lassen. Vorher stand hier
|
||||
// ein <p>: ein Etikett ohne Klickziel, aus dem es kein Zurück gab.
|
||||
if (watched) {
|
||||
return (
|
||||
<p className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
title="Markierung wieder entfernen"
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await unmarkMovieAction(entryId));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="group inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)] transition-colors hover:border-[color-mix(in_oklab,var(--fl-success)_70%,transparent)] hover:bg-[color-mix(in_oklab,var(--fl-success)_20%,transparent)] disabled:opacity-60"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
|
||||
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Gesehen
|
||||
</p>
|
||||
<span className="text-xs text-[color-mix(in_oklab,var(--fl-success)_75%,var(--fl-muted))]">
|
||||
— Markierung entfernen
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createOrderAction,
|
||||
createUniverseAction,
|
||||
deleteOrderAction,
|
||||
setDefaultOrderAction,
|
||||
shareUniverseAction,
|
||||
trashUniverseAction,
|
||||
unshareUniverseAction,
|
||||
@@ -109,6 +110,24 @@ export function OrderTabs({
|
||||
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
{order.name}
|
||||
{order.isDefault && (
|
||||
<>
|
||||
{/* Häkchen an der Reihenfolge, die beim Öffnen erscheint. Das
|
||||
Wort dahinter steht nur für Screenreader — sichtbar würde
|
||||
es die Reiter auseinanderziehen. */}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="ml-1.5 inline h-3.5 w-3.5 align-[-2px]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="sr-only"> (Standard)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -158,6 +177,21 @@ export function OrderTabs({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{active && !active.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await setDefaultOrderAction(active.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
|
||||
>
|
||||
Als Standard festlegen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{active && active.kind === "CUSTOM" && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+33
-1
@@ -110,7 +110,14 @@ export async function setStatus(userId: string, entryId: string, status: EntrySt
|
||||
export async function updateEntryDetails(
|
||||
userId: string,
|
||||
entryId: string,
|
||||
data: { rating?: number | null; notes?: string | null; whereToWatch?: string | null; favorite?: boolean },
|
||||
data: {
|
||||
rating?: number | null;
|
||||
notes?: string | null;
|
||||
whereToWatch?: string | null;
|
||||
favorite?: boolean;
|
||||
owned?: boolean;
|
||||
ownedSource?: string | null;
|
||||
},
|
||||
) {
|
||||
await requireEntry(userId, entryId);
|
||||
|
||||
@@ -127,6 +134,10 @@ export async function updateEntryDetails(
|
||||
? { whereToWatch: data.whereToWatch?.trim() || null }
|
||||
: {}),
|
||||
...(data.favorite !== undefined ? { favorite: data.favorite } : {}),
|
||||
...(data.owned !== undefined ? { owned: data.owned } : {}),
|
||||
...(data.ownedSource !== undefined
|
||||
? { ownedSource: data.ownedSource?.trim() || null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -327,6 +338,27 @@ export async function markMovieWatched(
|
||||
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt die Sichtung eines Films im aktuellen Durchlauf zurück.
|
||||
*
|
||||
* Das Gegenstück zu `markMovieWatched()` — und das Pendant zu
|
||||
* `unmarkEpisodes()` bei Serien.
|
||||
*
|
||||
* Die Eingrenzung auf `run` ist wesentlich: wer einen Film dreimal gesehen
|
||||
* hat, hat drei Sichtungen. Ohne `run` löschte das Zurücknehmen alle drei und
|
||||
* verfälschte rückwirkend die Statistik. Zurückgenommen wird nur, was im
|
||||
* laufenden Durchlauf abgehakt wurde.
|
||||
*/
|
||||
export async function unmarkMovie(userId: string, entryId: string) {
|
||||
const entry = await requireEntry(userId, entryId);
|
||||
if (entry.title.type !== "MOVIE") throw new LibraryError("Das ist keine Filmsichtung.");
|
||||
|
||||
await prisma.watch.deleteMany({
|
||||
where: { userId, entryId, episodeId: null, run: entry.currentRun },
|
||||
});
|
||||
return reconcileStatus(userId, entryId);
|
||||
}
|
||||
|
||||
/** Macht eine Sichtungsaktion rückgängig. */
|
||||
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
|
||||
await requireEntry(userId, entryId);
|
||||
|
||||
+12
-1
@@ -19,6 +19,8 @@ export type LibraryFilters = {
|
||||
genre?: string;
|
||||
tag?: string;
|
||||
provider?: string;
|
||||
/** Nur Titel, die in der eigenen Sammlung liegen. */
|
||||
owned?: boolean;
|
||||
sort?: LibrarySort;
|
||||
};
|
||||
|
||||
@@ -42,6 +44,8 @@ export type LibraryItem = {
|
||||
favorite: boolean;
|
||||
currentRun: number;
|
||||
whereToWatch: string | null;
|
||||
owned: boolean;
|
||||
ownedSource: string | null;
|
||||
tags: string[];
|
||||
watched: number;
|
||||
total: number;
|
||||
@@ -56,6 +60,7 @@ export async function getLibrary(
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.owned ? { owned: true } : {}),
|
||||
...(filters.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}),
|
||||
title: {
|
||||
...(filters.type ? { type: filters.type } : {}),
|
||||
@@ -116,6 +121,8 @@ export async function getLibrary(
|
||||
favorite: entry.favorite,
|
||||
currentRun: entry.currentRun,
|
||||
whereToWatch: entry.whereToWatch,
|
||||
owned: entry.owned,
|
||||
ownedSource: entry.ownedSource,
|
||||
tags: entry.tags.map((link) => link.tag.name),
|
||||
watched,
|
||||
total,
|
||||
@@ -162,11 +169,13 @@ export type LibraryFacets = {
|
||||
tags: string[];
|
||||
providers: string[];
|
||||
counts: Record<EntryStatus | "ALLE", number>;
|
||||
/** Wie viele Titel in der eigenen Sammlung liegen. */
|
||||
owned: number;
|
||||
};
|
||||
|
||||
/** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */
|
||||
export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
|
||||
const [genreRows, tagRows, providerRows, statusRows, total] = await Promise.all([
|
||||
const [genreRows, tagRows, providerRows, statusRows, total, owned] = await Promise.all([
|
||||
prisma.$queryRaw<{ genre: string }[]>`
|
||||
SELECT DISTINCT unnest(t."genres") AS genre
|
||||
FROM "LibraryEntry" e
|
||||
@@ -193,6 +202,7 @@ export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.libraryEntry.count({ where: { userId, deletedAt: null } }),
|
||||
prisma.libraryEntry.count({ where: { userId, deletedAt: null, owned: true } }),
|
||||
]);
|
||||
|
||||
const counts = {
|
||||
@@ -211,6 +221,7 @@ export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
|
||||
tags: tagRows.map((row) => row.name),
|
||||
providers: providerRows.map((row) => row.name),
|
||||
counts,
|
||||
owned,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,56 @@ async function notifyAvailability(titleId: string, titleName: string): Promise<n
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet Filme von der Watchlist, die gerade erschienen sind.
|
||||
*
|
||||
* Betrachtet wird ein Fenster der letzten Tage statt eines Zustandswechsels:
|
||||
* TMDB verschiebt Termine gelegentlich, und ein einmal verpasstes Datum soll
|
||||
* nicht dazu führen, dass die Meldung für immer ausbleibt. Doppelt kommt sie
|
||||
* trotzdem nie — dafür sorgt der Dedupe-Schlüssel.
|
||||
*/
|
||||
async function notifyReleases(windowDays = 7): Promise<number> {
|
||||
const from = new Date(Date.now() - windowDays * 86_400_000);
|
||||
const to = new Date();
|
||||
|
||||
const titles = await prisma.title.findMany({
|
||||
where: {
|
||||
type: "MOVIE",
|
||||
releaseDate: { gte: from, lte: to },
|
||||
entries: { some: { deletedAt: null, status: "WATCHLIST" } },
|
||||
},
|
||||
select: { id: true, title: true, releaseDate: true },
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
for (const title of titles) {
|
||||
const watchers = await prisma.libraryEntry.findMany({
|
||||
where: {
|
||||
titleId: title.id,
|
||||
deletedAt: null,
|
||||
status: "WATCHLIST",
|
||||
user: { notifyRelease: true, isActive: true },
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
for (const watcher of watchers) {
|
||||
const result = await notifyUser(watcher.userId, {
|
||||
subject: `Jetzt erschienen: ${title.title}`,
|
||||
body: `„${title.title}" von deiner Watchlist ist seit dem ${title.releaseDate?.toLocaleDateString(
|
||||
"de-DE",
|
||||
{ day: "2-digit", month: "2-digit", year: "numeric" },
|
||||
)} draußen.`,
|
||||
path: `/titel/${title.id}`,
|
||||
dedupeKey: `release:${title.id}`,
|
||||
});
|
||||
sent += result.sent;
|
||||
}
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Räumt den Papierkorb und abgelaufene Sitzungen. */
|
||||
export async function purgeExpired(): Promise<{ entries: number; sessions: number }> {
|
||||
const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 86_400_000);
|
||||
@@ -223,6 +273,16 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
|
||||
stats.errors.push("Kein TMDB-Schlüssel hinterlegt — der Abgleich wurde übersprungen.");
|
||||
}
|
||||
|
||||
// Erscheinungsmeldungen hängen nicht am TMDB-Abruf: sie stützen sich auf
|
||||
// die Erscheinungsdaten, die schon im Katalog stehen.
|
||||
try {
|
||||
stats.notificationsSent += await notifyReleases();
|
||||
} catch (error) {
|
||||
stats.errors.push(
|
||||
`Erscheinungsmeldungen: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const purged = await purgeExpired();
|
||||
stats.entriesPurged = purged.entries;
|
||||
stats.sessionsPurged = purged.sessions;
|
||||
|
||||
+18
-1
@@ -135,6 +135,11 @@ const CSV_HEADERS: Record<string, string> = {
|
||||
notiz: "notes",
|
||||
notes: "notes",
|
||||
kommentar: "notes",
|
||||
habich: "owned",
|
||||
owned: "owned",
|
||||
besitz: "owned",
|
||||
sammlung: "ownedSource",
|
||||
quelle: "ownedSource",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -185,6 +190,8 @@ export async function importCsv(userId: string, text: string): Promise<ImportSum
|
||||
rating: record.rating ? Number.parseInt(record.rating, 10) : null,
|
||||
year: record.year ? Number.parseInt(record.year, 10) : null,
|
||||
notes: record.notes || null,
|
||||
owned: /^(ja|yes|true|1|x)$/i.test(record.owned ?? ""),
|
||||
ownedSource: record.ownedSource || null,
|
||||
});
|
||||
if (result === "added") summary.added += 1;
|
||||
else summary.skipped += 1;
|
||||
@@ -299,6 +306,8 @@ type ImportRecord = {
|
||||
rating: number | null;
|
||||
year: number | null;
|
||||
notes: string | null;
|
||||
owned?: boolean;
|
||||
ownedSource?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -353,12 +362,14 @@ async function importOne(userId: string, record: ImportRecord): Promise<"added"
|
||||
|
||||
const entry = await addToLibrary(userId, titleId, record.status);
|
||||
|
||||
if (record.rating || record.notes) {
|
||||
if (record.rating || record.notes || record.owned || record.ownedSource) {
|
||||
await prisma.libraryEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
rating: record.rating && record.rating >= 1 && record.rating <= 10 ? record.rating : null,
|
||||
notes: record.notes,
|
||||
owned: record.owned ?? false,
|
||||
ownedSource: record.ownedSource ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -394,6 +405,8 @@ export async function exportCsv(userId: string): Promise<string> {
|
||||
"Status",
|
||||
"Bewertung",
|
||||
"Durchlaeufe",
|
||||
"HabIch",
|
||||
"Sammlung",
|
||||
"WoGeschaut",
|
||||
"Tags",
|
||||
"Notiz",
|
||||
@@ -410,6 +423,8 @@ export async function exportCsv(userId: string): Promise<string> {
|
||||
entry.status,
|
||||
entry.rating ?? "",
|
||||
entry.currentRun + 1,
|
||||
entry.owned ? "ja" : "nein",
|
||||
entry.ownedSource ?? "",
|
||||
entry.whereToWatch ?? "",
|
||||
entry.tags.map((link) => link.tag.name).join(" "),
|
||||
entry.notes?.replace(/\r?\n/g, " ") ?? "",
|
||||
@@ -479,6 +494,8 @@ export async function exportJson(userId: string): Promise<object> {
|
||||
rating: entry.rating,
|
||||
notes: entry.notes,
|
||||
whereToWatch: entry.whereToWatch,
|
||||
owned: entry.owned,
|
||||
ownedSource: entry.ownedSource,
|
||||
favorite: entry.favorite,
|
||||
currentRun: entry.currentRun,
|
||||
tags: entry.tags.map((link) => link.tag.name),
|
||||
|
||||
+43
-2
@@ -244,13 +244,54 @@ export async function renameOrder(userId: string, orderId: string, name: string)
|
||||
export async function deleteOrder(userId: string, orderId: string) {
|
||||
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
select: { universeId: true, kind: true },
|
||||
select: { universeId: true, kind: true, isDefault: true },
|
||||
});
|
||||
await requireWrite(userId, order.universeId);
|
||||
if (order.kind === "RELEASE") {
|
||||
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
|
||||
}
|
||||
await prisma.universeOrder.delete({ where: { id: orderId } });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.universeOrder.delete({ where: { id: orderId } });
|
||||
|
||||
// War die gelöschte der Standard, stünde das Universum ohne einen da und
|
||||
// die Auswahl fiele auf „die erste" — Zufall statt Absicht. Die
|
||||
// Erscheinungsreihenfolge gehört immer dazu, sie übernimmt.
|
||||
if (order.isDefault) {
|
||||
await tx.universeOrder.updateMany({
|
||||
where: { universeId: order.universeId, kind: "RELEASE" },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt fest, welche Reihenfolge beim Öffnen des Universums erscheint.
|
||||
*
|
||||
* Der Standard gehört dem Universum, nicht der einzelnen Person: wer
|
||||
* Schreibrecht hat, setzt ihn für alle, die das Universum sehen — so wie die
|
||||
* Reihenfolgen selbst auch geteilt sind.
|
||||
*
|
||||
* Zurücksetzen und Setzen laufen gemeinsam in einer Transaktion. Sonst stünden
|
||||
* dazwischen zwei Standards nebeneinander, und die Auswahl in `getUniverse()`
|
||||
* griffe sich nach Sortierzufall einen davon.
|
||||
*/
|
||||
export async function setDefaultOrder(userId: string, orderId: string) {
|
||||
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
select: { universeId: true, isDefault: true },
|
||||
});
|
||||
await requireWrite(userId, order.universeId);
|
||||
if (order.isDefault) return;
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.universeOrder.updateMany({
|
||||
where: { universeId: order.universeId, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
}),
|
||||
prisma.universeOrder.update({ where: { id: orderId }, data: { isDefault: true } }),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
startRewatch,
|
||||
undoWatches,
|
||||
unmarkEpisodes,
|
||||
unmarkMovie,
|
||||
updateEntryDetails,
|
||||
watchNextEpisode,
|
||||
} from "../src/lib/library";
|
||||
@@ -336,3 +337,108 @@ test("fremde Einträge sind nicht erreichbar", async () => {
|
||||
|
||||
await prisma.user.delete({ where: { id: other.id } });
|
||||
});
|
||||
|
||||
test("Sammlung ist unabhängig von der Streaming-Verfügbarkeit", async () => {
|
||||
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||
|
||||
const fresh = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
|
||||
assert.equal(fresh.owned, false, "standardmäßig gehört einem nichts");
|
||||
assert.equal(fresh.ownedSource, null);
|
||||
|
||||
const owned = await updateEntryDetails(userId, entry.id, {
|
||||
owned: true,
|
||||
ownedSource: " Jellyfin ",
|
||||
});
|
||||
assert.equal(owned.owned, true);
|
||||
assert.equal(owned.ownedSource, "Jellyfin", "Rand wird beschnitten");
|
||||
|
||||
// Der Fundort bleibt davon unberührt: das eine sagt, wo es läuft,
|
||||
// das andere, wo es bei mir liegt.
|
||||
assert.equal(
|
||||
owned.whereToWatch,
|
||||
"Disney Plus",
|
||||
"der Vorschlag aus der Verfügbarkeit bleibt davon unberührt",
|
||||
);
|
||||
|
||||
const released = await updateEntryDetails(userId, entry.id, {
|
||||
owned: false,
|
||||
ownedSource: "",
|
||||
});
|
||||
assert.equal(released.owned, false);
|
||||
assert.equal(released.ownedSource, null, "leerer Text wird zu null");
|
||||
});
|
||||
|
||||
test("Sammlung: Filter und Zähler in der Bibliothek", async () => {
|
||||
const movie = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||
const series = await addToLibrary(userId, seriesTitleId, "WATCHING");
|
||||
await updateEntryDetails(userId, movie.id, { owned: true, ownedSource: "Blu-ray" });
|
||||
|
||||
const { getLibrary, getLibraryFacets } = await import("../src/lib/queries");
|
||||
|
||||
const alle = await getLibrary(userId, {});
|
||||
assert.equal(alle.length, 2);
|
||||
|
||||
const nurSammlung = await getLibrary(userId, { owned: true });
|
||||
assert.equal(nurSammlung.length, 1);
|
||||
assert.equal(nurSammlung[0]?.entryId, movie.id);
|
||||
assert.equal(nurSammlung[0]?.ownedSource, "Blu-ray");
|
||||
|
||||
const facets = await getLibraryFacets(userId);
|
||||
assert.equal(facets.owned, 1, "der Zähler der Filterleiste stimmt");
|
||||
assert.equal(facets.counts.ALLE, 2);
|
||||
|
||||
await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } });
|
||||
});
|
||||
|
||||
test("Filme: die Sichtung laesst sich wieder zuruecknehmen", async () => {
|
||||
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||
|
||||
await markMovieWatched(userId, entry.id);
|
||||
assert.equal((await getProgress(userId, entry.id)).percent, 100);
|
||||
|
||||
await unmarkMovie(userId, entry.id);
|
||||
|
||||
const zurueck = await getProgress(userId, entry.id);
|
||||
assert.equal(zurueck.percent, 0, "der Fortschritt steht wieder auf null");
|
||||
assert.equal(zurueck.minutesWatched, 0);
|
||||
assert.equal(
|
||||
await prisma.watch.count({ where: { userId, entryId: entry.id } }),
|
||||
0,
|
||||
"die Sichtung ist weg",
|
||||
);
|
||||
|
||||
// Und danach laesst es sich erneut abhaken — kein Einbahnzustand.
|
||||
const erneut = await markMovieWatched(userId, entry.id);
|
||||
assert.equal(erneut.watchIds.length, 1);
|
||||
});
|
||||
|
||||
test("Filme: das Zuruecknehmen betrifft nur den laufenden Durchlauf", async () => {
|
||||
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||
|
||||
await markMovieWatched(userId, entry.id);
|
||||
await startRewatch(userId, entry.id);
|
||||
await markMovieWatched(userId, entry.id);
|
||||
assert.equal(await prisma.watch.count({ where: { userId, entryId: entry.id } }), 2);
|
||||
|
||||
await unmarkMovie(userId, entry.id);
|
||||
|
||||
const rest = await prisma.watch.findMany({
|
||||
where: { userId, entryId: entry.id },
|
||||
select: { run: true },
|
||||
});
|
||||
assert.deepEqual(
|
||||
rest.map((watch) => watch.run),
|
||||
[0],
|
||||
"die Sichtung des ersten Durchlaufs bleibt fuer die Statistik stehen",
|
||||
);
|
||||
assert.equal(
|
||||
(await getProgress(userId, entry.id)).percent,
|
||||
0,
|
||||
"der laufende Durchlauf steht wieder auf null",
|
||||
);
|
||||
});
|
||||
|
||||
test("unmarkMovie weist Serien ab", async () => {
|
||||
const entry = await addToLibrary(userId, seriesTitleId, "WATCHING");
|
||||
await assert.rejects(() => unmarkMovie(userId, entry.id), /keine Filmsichtung/);
|
||||
});
|
||||
|
||||
+37
-1
@@ -8,7 +8,7 @@ import { prisma } from "../src/lib/db";
|
||||
import { importMovie, importSeries } from "../src/lib/catalog";
|
||||
import { addToLibrary, markMovieWatched, moveToTrash } from "../src/lib/library";
|
||||
import { exportCsv, exportJson, importCsv, importTrakt, parseCsv } from "../src/lib/transfer";
|
||||
import { purgeExpired, TRASH_RETENTION_DAYS } from "../src/lib/sync";
|
||||
import { purgeExpired, runSync, TRASH_RETENTION_DAYS } from "../src/lib/sync";
|
||||
import { notifyUser } from "../src/lib/notify";
|
||||
|
||||
/** Prüft Import, Export, das Aufräumen des Papierkorbs und den Doppelversandschutz. */
|
||||
@@ -239,3 +239,39 @@ test("dieselbe Nachricht wird nicht zweimal verschickt", async () => {
|
||||
|
||||
await prisma.notificationChannel.deleteMany({ where: { userId } });
|
||||
});
|
||||
|
||||
test("erschienene Watchlist-Filme werden gemeldet — und nur einmal", async () => {
|
||||
// Der Film liegt auf der Watchlist und ist gestern erschienen.
|
||||
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||
await prisma.title.update({
|
||||
where: { id: movieTitleId },
|
||||
data: { releaseDate: new Date(Date.now() - 86_400_000) },
|
||||
});
|
||||
await prisma.user.update({ where: { id: userId }, data: { notifyRelease: true } });
|
||||
|
||||
await prisma.notificationChannel.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: "WEBHOOK",
|
||||
name: "Test",
|
||||
config: { webhookUrl: "http://127.0.0.1:9/nicht-erreichbar" },
|
||||
},
|
||||
});
|
||||
|
||||
await runSync({ limit: 0 });
|
||||
const first = await prisma.notificationLog.findMany({
|
||||
where: { userId, dedupeKey: `release:${movieTitleId}` },
|
||||
});
|
||||
assert.equal(first.length, 1, "die Meldung wird angelegt");
|
||||
assert.match(first[0]?.subject ?? "", /Jetzt erschienen/);
|
||||
|
||||
// Ein zweiter Lauf meldet dasselbe nicht noch einmal.
|
||||
await runSync({ limit: 0 });
|
||||
const second = await prisma.notificationLog.count({
|
||||
where: { userId, dedupeKey: `release:${movieTitleId}` },
|
||||
});
|
||||
assert.equal(second, 1);
|
||||
|
||||
await prisma.notificationChannel.deleteMany({ where: { userId } });
|
||||
await prisma.libraryEntry.delete({ where: { id: entry.id } });
|
||||
});
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
addTitle,
|
||||
createOrder,
|
||||
createUniverse,
|
||||
deleteOrder,
|
||||
getAccess,
|
||||
getUniverse,
|
||||
listUniverses,
|
||||
setDefaultOrder,
|
||||
setOrderPositions,
|
||||
shareUniverse,
|
||||
trashUniverse,
|
||||
@@ -280,3 +282,72 @@ test("gelöschte Universen verschwinden aus der Übersicht", async () => {
|
||||
const list = await listUniverses(ownerId);
|
||||
assert.equal(list.find((item) => item.id === universe.id), undefined);
|
||||
});
|
||||
|
||||
// --- Standardreihenfolge ------------------------------------------------------
|
||||
|
||||
test("die Standardreihenfolge lässt sich umstellen und bleibt eindeutig", async () => {
|
||||
const universe = await createUniverse(ownerId, "Standardwechsel");
|
||||
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||
|
||||
// Frisch angelegt führt die Erscheinungsreihenfolge.
|
||||
const vorher = await getUniverse(ownerId, universe.id);
|
||||
assert.equal(vorher?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
|
||||
|
||||
await setDefaultOrder(ownerId, order.id);
|
||||
|
||||
// Ohne Angabe in der Adresse erscheint jetzt die eigene Reihenfolge.
|
||||
const nachher = await getUniverse(ownerId, universe.id);
|
||||
assert.equal(nachher?.activeOrderId, order.id);
|
||||
|
||||
const standards = nachher!.orders.filter((o) => o.isDefault);
|
||||
assert.equal(standards.length, 1, "es steht immer genau ein Standard");
|
||||
assert.equal(standards[0]!.id, order.id, "und zwar der zuletzt gesetzte");
|
||||
});
|
||||
|
||||
test("die Adresse schlägt die Standardreihenfolge", async () => {
|
||||
const universe = await createUniverse(ownerId, "Adressvorrang");
|
||||
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||
await setDefaultOrder(ownerId, order.id);
|
||||
|
||||
const release = (await getUniverse(ownerId, universe.id))!.orders.find(
|
||||
(o) => o.kind === "RELEASE",
|
||||
)!;
|
||||
const view = await getUniverse(ownerId, universe.id, release.id);
|
||||
|
||||
assert.equal(view?.activeOrderId, release.id, "die angeforderte gewinnt gegen den Standard");
|
||||
});
|
||||
|
||||
test("wird die Standardreihenfolge gelöscht, übernimmt die Erscheinungsreihenfolge", async () => {
|
||||
const universe = await createUniverse(ownerId, "Rueckfall");
|
||||
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||
await setDefaultOrder(ownerId, order.id);
|
||||
|
||||
await deleteOrder(ownerId, order.id);
|
||||
|
||||
const view = await getUniverse(ownerId, universe.id);
|
||||
const standards = view!.orders.filter((o) => o.isDefault);
|
||||
assert.equal(standards.length, 1, "das Universum bleibt nicht ohne Standard zurück");
|
||||
assert.equal(standards[0]!.kind, "RELEASE");
|
||||
assert.equal(view?.activeOrderId, standards[0]!.id);
|
||||
});
|
||||
|
||||
test("den Standard setzen darf nur, wer schreiben darf", async () => {
|
||||
const universe = await createUniverse(ownerId, "Rechteprobe");
|
||||
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||
|
||||
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "READ");
|
||||
await assert.rejects(() => setDefaultOrder(guestId, order.id), /nur ansehen/);
|
||||
|
||||
// Der Standard steht danach unverändert auf der Erscheinungsreihenfolge.
|
||||
const unveraendert = await getUniverse(ownerId, universe.id);
|
||||
assert.equal(unveraendert?.orders.find((o) => o.isDefault)?.kind, "RELEASE");
|
||||
|
||||
// Mit Schreibrecht geht es dann.
|
||||
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "WRITE");
|
||||
await setDefaultOrder(guestId, order.id);
|
||||
assert.equal((await getUniverse(guestId, universe.id))?.activeOrderId, order.id);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/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.
|
||||
|
||||
Geprüft wird zuerst http://127.0.0.1:APP_PORT/api/health. Ist der Port gar
|
||||
nicht auf dem Host veröffentlicht — üblich hinter einem Reverse Proxy —,
|
||||
zählt stattdessen der Healthcheck des Containers.
|
||||
|
||||
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."
|
||||
|
||||
# Der Container meldet seinen Zustand selbst — der HEALTHCHECK im Image fragt
|
||||
# /api/health von innen ab. Das ist der verlässlichere Weg: hinter einem
|
||||
# Reverse Proxy ist der App-Port oft gar nicht auf dem Host veröffentlicht,
|
||||
# und dann sagt ein fehlschlagendes curl nichts über die Instanz aus.
|
||||
# Gibt immer 0 zurück: die Funktion läuft auch im Fehlerpfad, und unter
|
||||
# `set -e` dürfte eine Auskunft über den Zustand nicht selbst das Skript
|
||||
# abbrechen, bevor der Weg zurück ausgegeben wurde.
|
||||
container_zustand() {
|
||||
local id
|
||||
id=$($COMPOSE ps -q app 2>/dev/null || true)
|
||||
if [ -z "$id" ]; then
|
||||
printf 'kein-container'
|
||||
return 0
|
||||
fi
|
||||
# Ohne HEALTHCHECK im Image ist .State.Health leer — dann bleibt der
|
||||
# Laufzustand (running/exited) als grobe Auskunft.
|
||||
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}ohne-healthcheck:{{.State.Status}}{{end}}' \
|
||||
"$id" 2>/dev/null || printf 'unbekannt'
|
||||
return 0
|
||||
}
|
||||
|
||||
GESUND=0
|
||||
UEBER_CONTAINER=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
|
||||
if [ "$(container_zustand)" = "healthy" ]; then
|
||||
GESUND=1
|
||||
UEBER_CONTAINER=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$GESUND" -eq 1 ]; then
|
||||
if [ "$UEBER_CONTAINER" -eq 1 ]; then
|
||||
gut "Der Container meldet sich gesund."
|
||||
leise "Auf Port ${PORT} war nichts zu erreichen — das ist normal, wenn die App"
|
||||
leise "nur über einen Reverse Proxy erreichbar ist und den Port nicht veröffentlicht."
|
||||
else
|
||||
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true)
|
||||
gut "Flimmer antwortet auf Port ${PORT}."
|
||||
leise "$ANTWORT"
|
||||
fi
|
||||
|
||||
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
|
||||
if [ -n "$SICHERUNG" ]; then
|
||||
leise "Sicherung: $SICHERUNG"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Fehlschlag: konkreter Weg zurück ----------------------------------------
|
||||
|
||||
printf '\n%s✗ Die Instanz antwortet nach zwei Minuten nicht.%s\n\n' "$FEHLER" "$AUS" >&2
|
||||
|
||||
# Der Container-Zustand grenzt ein, wo es klemmt — ohne ihn sähe ein nicht
|
||||
# veröffentlichter Port genauso aus wie eine abgestürzte App.
|
||||
ZUSTAND=$(container_zustand)
|
||||
case "$ZUSTAND" in
|
||||
kein-container)
|
||||
info "Es läuft gar kein app-Container. Der Start ist offenbar fehlgeschlagen."
|
||||
;;
|
||||
starting)
|
||||
info "Der Container startet noch. Möglicherweise dauern die Migrationen länger —"
|
||||
info "sieh im Log nach und gib ihm etwas Zeit."
|
||||
;;
|
||||
unhealthy)
|
||||
info "Der Container läuft, meldet sich aber als nicht gesund. Der Fehler liegt"
|
||||
info "also in der Anwendung, nicht am Port."
|
||||
;;
|
||||
ohne-healthcheck:running)
|
||||
info "Der Container läuft, das Image bringt aber keinen Healthcheck mit (ältere"
|
||||
info "Fassung). Ob die App wirklich antwortet, verrät nur das Log."
|
||||
;;
|
||||
ohne-healthcheck:*)
|
||||
info "Der Container ist nicht gelaufen: ${ZUSTAND#ohne-healthcheck:}"
|
||||
;;
|
||||
*)
|
||||
info "Container-Zustand: ${ZUSTAND}"
|
||||
;;
|
||||
esac
|
||||
printf '\n'
|
||||
|
||||
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