10 Commits
Author SHA1 Message Date
Florian Wilde 9ee8664233 feat: eigene Sammlung — „hab ich" mit Quelle
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s
Streaming-Verfügbarkeit beantwortet nur die halbe Frage: manche Filme laufen
nirgends mehr, die hat man gekauft und liegen zu Hause — in Jellyfin, Plex,
Emby, auf dem NAS oder als Scheibe.

Bewusst getrennt vom Feld „wo schaue ich das": das eine sagt, wo ein Titel
gerade läuft, und das ändert sich ständig; das andere sagt, was einem gehört,
und das bleibt. Auf der Detailseite steht die Sammlung deshalb vor der
Verfügbarkeit — was man hat, ist die verlässlichere Antwort.

Durchgezogen bis in Filter, Export, CSV-Import und REST-API.
2026-09-01 21:15:53 +02:00
Florian Wilde cd9953c47c chore: ueberfluessiges @types/bcryptjs entfernen
bcryptjs bringt eigene Typdefinitionen mit; das Stub-Paket war nur noch eine
Deprecation-Warnung beim Installieren.
2026-09-01 20:59:31 +02:00
Florian Wilde 18c87e6b2e fix: Schrift mitliefern statt beim Bauen herunterladen
next/font/google laedt Outfit beim Bauen von Google — das macht den Build von
einer Netzverbindung abhaengig und liess ihn im Container haengen. Die
variable Fassung liegt jetzt als Datei im Repository; weder beim Bauen noch
zur Laufzeit geht eine Anfrage nach aussen.

Ausserdem: npm blockiert seit Version 11 Install-Skripte. Fuer prisma und
@prisma/engines sind sie jetzt gezielt erlaubt, damit im Image die
plattformpassende Engine bereitliegt.
2026-09-01 20:56:32 +02:00
Florian Wilde afa3d74248 docs: Changelog und Release-Notes fuer 0.6.1 vorbereiten
CI / pruefen (push) Canceled after 0s
2026-09-01 20:52:52 +02:00
Florian Wilde b3eb946cfa docs: Diagrammfarben in der Design-Dokumentation festhalten 2026-09-01 20:51:11 +02:00
Florian Wilde 29ef710b9b docs: CI-Ablaeufe in der README erwaehnen 2026-09-01 20:50:47 +02:00
Florian Wilde 3db6241e9a chore: ungenutzte Bildkonfiguration entfernen
next/image kommt nirgends zum Einsatz — Bilder laufen ueber den eigenen
Cache-Endpunkt. Damit entfaellt auch das sicherheitsrelevant aussehende
dangerouslyAllowSVG, das ohnehin wirkungslos war.
2026-09-01 20:50:18 +02:00
Florian Wilde d8239441aa ci: Workflows für Gitea Actions
CI prüft bei jedem Push Typen, Tests und den Produktionsbuild — die Tests
laufen gegen eine echte PostgreSQL-Instanz, weil sie Abfragen und
Fortschrittsberechnungen prüfen, nicht Attrappen.

Der Release-Workflow zieht die Notizen aus releases/vX.Y.Z.md, derselben
Datei, die auch von Hand für ein Release taugt. So gibt es eine Quelle für
den Text, und der Ablauf funktioniert mit und ohne CI.
2026-09-01 20:50:17 +02:00
Florian Wilde 96a42594a9 feat: Erscheinungsmeldung für Watchlist-Filme
Der Auftrag nennt sie ausdrücklich, das Profil hatte den Schalter schon —
verschickt wurde bisher nichts.

Betrachtet wird ein Zeitfenster der letzten Tage statt eines Zustandswechsels:
TMDB verschiebt Termine gelegentlich, und ein einmal verpasstes Datum soll
nicht dazu führen, dass die Meldung für immer ausbleibt. Doppelt kommt sie
trotzdem nie.

Die Meldung hängt nicht am TMDB-Abruf, sondern an den Erscheinungsdaten, die
ohnehin im Katalog stehen — sie funktioniert also auch ohne Schlüssel.
2026-09-01 20:50:17 +02:00
Florian Wilde a54e625ca8 docs: oeffentliches Profil in der Anleitung ergaenzen 2026-09-01 20:37:58 +02:00
39 changed files with 856 additions and 71 deletions
+63
View File
@@ -0,0 +1,63 @@
# Prüft jeden Push: Typen, Tests, Produktionsbuild.
#
# Die Tests brauchen eine echte PostgreSQL-Instanz — sie prüfen Abfragen und
# Fortschrittsberechnungen gegen die Datenbank, nicht gegen Attrappen. Einen
# TMDB-Schlüssel brauchen sie nicht: die TMDB-Antworten sind nachgebildet.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
pruefen:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: flimmer
POSTGRES_PASSWORD: flimmer-ci
POSTGRES_DB: flimmer
options: >-
--health-cmd "pg_isready -U flimmer -d flimmer"
--health-interval 5s
--health-timeout 5s
--health-retries 12
ports:
- 5432:5432
env:
DATABASE_URL: postgresql://flimmer:flimmer-ci@localhost:5432/flimmer?schema=public
AUTH_SECRET: ci-geheimnis-0123456789abcdef0123456789abcdef
APP_URL: http://localhost:3000
TZ: Europe/Berlin
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Abhängigkeiten installieren
run: npm ci --no-audit --no-fund
- name: Prisma-Client erzeugen
run: npx prisma generate
- name: Schema einspielen
run: npx prisma migrate deploy
- name: Typen prüfen
run: npm run typecheck
- name: Tests
run: npm test
- name: Produktionsbuild
run: npx next build
+56
View File
@@ -0,0 +1,56 @@
# Legt beim Pushen eines Versions-Tags automatisch das Release an.
#
# Die Notizen kommen aus releases/vX.Y.Z.md — derselben Datei, die auch von
# Hand für ein Release taugt. So gibt es genau eine Quelle für den Text, und
# der Ablauf funktioniert mit und ohne CI.
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
veroeffentlichen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Release-Notizen einsammeln
id: notes
run: |
VERSION="${GITHUB_REF_NAME#v}"
FILE="releases/${GITHUB_REF_NAME}.md"
if [ ! -f "$FILE" ]; then
echo "Es gibt keine $FILE — bitte anlegen, bevor der Tag gepusht wird." >&2
exit 1
fi
# Die Überschrift der Datei ist der Titel; alles darunter der Text.
{
echo "version=$VERSION"
echo "body<<FLIMMER_EOF"
tail -n +2 "$FILE"
echo "FLIMMER_EOF"
} >> "$GITHUB_OUTPUT"
- name: Release anlegen
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
BODY: ${{ steps.notes.outputs.body }}
VERSION: ${{ steps.notes.outputs.version }}
run: |
PAYLOAD=$(jq -n \
--arg tag "$GITHUB_REF_NAME" \
--arg name "$VERSION" \
--arg body "$BODY" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
curl -sSf -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
+1
View File
@@ -42,3 +42,4 @@ Thumbs.db
/blob-report/ /blob-report/
.playwright/ .playwright/
src/generated/ src/generated/
.env.composetest
+62 -1
View File
@@ -7,6 +7,66 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.7.0] — 2026-09-01
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
gestartet, und dabei kamen Fehler ans Licht, die nur der echte Containerstart
zeigt.
### Hinzugefügt
- **Eigene Sammlung**: Jeder Eintrag lässt sich als „hab ich" markieren, mit
Angabe wo — Jellyfin, Plex, Emby, NAS, Blu-ray, DVD oder frei getippt.
Bewusst getrennt vom Feld „wo schaue ich das": Streaming-Verfügbarkeit kommt
und geht, der gekaufte Film auf dem Heimserver bleibt. Gerade die Titel, die
nirgends mehr laufen, will man ja wiederfinden.
- Abzeichen auf der Posterkachel und im Kopf der Detailseite
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit
— was man hat, ist die verlässlichere Antwort auf „wo kann ich das schauen"
- Eigener Filter in der Bibliothek samt Zähler
- In Export (CSV und JSON), CSV-Import und REST-API (`?sammlung=1` beim
Lesen, `owned` und `ownedSource` beim Ändern)
### Behoben
- **Der Container konnte seine Migrationen nicht einspielen.** Das Laufzeitimage
bekam die Prisma-CLI, indem einzelne Ordner aus `node_modules` kopiert wurden
— dabei fehlten transitive Abhängigkeiten von `@prisma/config` (`effect` und
weitere). Die CLI wird jetzt in einer eigenen Build-Stufe vollständig
installiert und liegt im Laufzeitimage unter `/node_modules`, wo sie den
getracten Abhängigkeitsbaum der Anwendung nicht stört.
- **Der Build hing im Container am Schriftabruf.** `next/font/google` lädt
Outfit beim Bauen von Google herunter — das macht den Build von einer
Netzverbindung abhängig und blieb im Container hängen. Die variable Fassung
liegt jetzt als Datei im Repository; weder beim Bauen noch zur Laufzeit geht
eine Anfrage nach außen. Für eine selbst gehostete Anwendung ist das ohnehin
das Richtige.
- **npm blockierte Prismas Install-Skripte** (Voreinstellung seit npm 11),
wodurch im Image die plattformpassende Engine fehlen konnte. Sie sind jetzt
gezielt für `prisma` und `@prisma/engines` erlaubt statt pauschal.
### Hinzugefügt
- **Erscheinungsmeldung für Watchlist-Filme.** Der Schalter dafür stand schon im
Profil, verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der
letzten Tage statt eines Zustandswechsels — TMDB verschiebt Termine
gelegentlich, und ein einmal verpasstes Datum soll die Meldung nicht für
immer verhindern.
- **Gitea-Actions-Workflows**: `ci.yml` prüft bei jedem Push Typen, Tests und
Produktionsbuild gegen eine echte PostgreSQL-Instanz; `release.yml` legt beim
Pushen eines Versions-Tags das Release mit den Notizen aus
`releases/vX.Y.Z.md` an.
- Diagrammfarben und ihre Prüfwerte in `docs/DESIGN.md` festgehalten; das
öffentliche Profil in die Anleitung aufgenommen.
### Entfernt
- Die ungenutzte `images`-Konfiguration in `next.config.ts`. `next/image` kommt
nirgends zum Einsatz — damit entfällt auch das sicherheitsrelevant aussehende
`dangerouslyAllowSVG`, das ohnehin wirkungslos war.
## [0.6.0] — 2026-09-01 ## [0.6.0] — 2026-09-01
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
@@ -261,7 +321,8 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
dass weitere Sprachen als zusätzliche Datei dazukommen können. dass weitere Sprachen als zusätzliche Datei dazukommen können.
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation. - **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.6.0...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.0...HEAD
[0.7.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.0
[0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0 [0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0 [0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
[0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0 [0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
+26 -2
View File
@@ -91,6 +91,18 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
--- ---
## Nach der Abnahme (v0.7.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 7.1 | 20:25 | 20:50 | 25 min | Docker-Image zum ersten Mal wirklich gebaut und gestartet; zwei Fehler behoben (fehlende Prisma-Abhängigkeiten im Laufzeitimage, Schriftabruf beim Bauen), Gitea-Workflows, Erscheinungsmeldung nachgezogen |
| 7.2 | 20:50 | 21:05 | 15 min | Compose-Stack von null verifiziert: Migration, erstes Konto wird Administrator, Verwaltung erreichbar |
| 7.3 | 21:05 | 21:16 | 11 min | Rückfrage aus dem Betrieb umgesetzt: „hab ich" für die eigene Sammlung (Jellyfin, Plex, NAS, Blu-ray) — Schema, Logik, Oberfläche, Filter, Import/Export, API, Tests, Anleitung |
**Nacharbeiten gesamt: 51 min** (2026-09-01, 20:25 – 21:16)
---
## Gesamtübersicht ## Gesamtübersicht
| Etappe | Version | Dauer | | Etappe | Version | Dauer |
@@ -101,8 +113,9 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| 4 — Universen | 0.4.0 | 14 min | | 4 — Universen | 0.4.0 | 14 min |
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min | | 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min | | 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
**Gesamtentwicklungszeit: 2 h 16 min** (136 min, 2026-09-01, 18:09 – 20:25) **Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16)
### Womit die Zeit verging ### Womit die Zeit verging
@@ -111,7 +124,7 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) | | ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) |
| ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) | | ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) |
| ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) | | ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) |
| ~20 min | Tests — 49 Stück, die unterwegs sieben echte Fehler gefunden haben | | ~25 min | Tests — 52 Stück, die unterwegs acht echte Fehler gefunden haben |
| ~15 min | Dokumentation, Screenshots, Changelog, Releases | | ~15 min | Dokumentation, Screenshots, Changelog, Releases |
| ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette | | ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette |
@@ -124,3 +137,14 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel. 5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel.
6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build. 6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build.
7. Der SVG-Titel eines Diagramms brach die Hydration. 7. Der SVG-Titel eines Diagramms brach die Hydration.
8. Eine Testerwartung zur Reihenfolge war falsch — nicht der Code.
### Fehler, die erst der echte Containerstart zeigte
1. Dem Laufzeitimage fehlten transitive Abhängigkeiten der Prisma-CLI, die
Migration lief nicht an. Die CLI wird jetzt in einer eigenen Build-Stufe
vollständig installiert.
2. npm blockiert seit Version 11 Install-Skripte; ohne Freigabe fehlte die
plattformpassende Prisma-Engine.
3. `next/font/google` lud die Schrift beim Bauen herunter und ließ den Build
im Container hängen. Die Schrift liegt jetzt im Repository.
+22 -3
View File
@@ -16,6 +16,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
# npm blockt Install-Skripte seit Version 11. Welche laufen dürfen, steht im
# Feld "allowScripts" der package.json — für Prisma, damit die zur Plattform
# passende Engine bereitgelegt wird.
RUN npm ci --no-audit --no-fund RUN npm ci --no-audit --no-fund
# --- Build ------------------------------------------------------------------- # --- Build -------------------------------------------------------------------
@@ -32,6 +35,18 @@ ENV NODE_ENV=production
RUN npx prisma generate && npx next build RUN npx prisma generate && npx next build
# --- Prisma-CLI --------------------------------------------------------------
# Eigene Stufe, damit die CLI samt ihrer transitiven Abhängigkeiten vollständig
# ins Laufzeitimage kommt. Einzelne Pakete aus node_modules herauszupicken ging
# schief: @prisma/config braucht weitere Module, die dabei fehlten.
FROM base AS prisma-cli
WORKDIR /cli
# Die package.json liegt als Datei bei: nur mit dem Feld "allowScripts" lässt
# npm die Install-Skripte von Prisma zu — sonst fehlt im Image die zur
# Plattform passende Engine.
COPY docker/prisma-cli-package.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund prisma@7.10.0
# --- Laufzeit ---------------------------------------------------------------- # --- Laufzeit ----------------------------------------------------------------
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
@@ -48,12 +63,16 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=flimmer:nodejs /app/.next/standalone ./ COPY --from=builder --chown=flimmer:nodejs /app/.next/standalone ./
COPY --from=builder --chown=flimmer:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=flimmer:nodejs /app/.next/static ./.next/static
# Prisma-CLI und Schema bleiben im Image, damit der Container beim Start seine # Schema und Migrationen bleiben im Image, damit der Container beim Start seine
# eigenen Migrationen ausspielen kann (`prisma migrate deploy`). # eigenen Migrationen ausspielen kann (`prisma migrate deploy`).
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.mjs ./prisma.config.mjs COPY --from=builder /app/prisma.config.mjs ./prisma.config.mjs
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma # Die CLI liegt bewusst unter /node_modules statt in /app/node_modules: dort
# stört sie den getracten Abhängigkeitsbaum der Anwendung nicht, und Node
# findet sie beim Auflösen von "prisma/config" trotzdem — die Suche wandert
# von /app aus nach oben.
COPY --from=prisma-cli /cli/node_modules /node_modules
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh \ RUN chmod +x /usr/local/bin/entrypoint.sh \
+10
View File
@@ -33,6 +33,9 @@ bleiben auf deinem Server.
Story-Chronologie, Einsteiger-Route) und Gesamtfortschritt. Story-Chronologie, Einsteiger-Route) und Gesamtfortschritt.
- **Metadaten von TMDB** — Suche, Poster, Staffeln und Episoden, offizielle - **Metadaten von TMDB** — Suche, Poster, Staffeln und Episoden, offizielle
Collections, Streaming-Verfügbarkeit in deiner Region. Collections, Streaming-Verfügbarkeit in deiner Region.
- **Eigene Sammlung** — markiere, was du schon hast, und wo: Jellyfin, Plex,
NAS, Blu-ray. Gerade die Titel, die nirgends mehr streamen, findest du so
wieder.
- **Benachrichtigungen** — bei neuen Episoden und Releases, per E-Mail, ntfy, - **Benachrichtigungen** — bei neuen Episoden und Releases, per E-Mail, ntfy,
Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar. Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar.
- **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen - **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen
@@ -206,6 +209,13 @@ docker/ Entrypoint des Containers
docs/ Anleitung mit Screenshots, API, Design-Dokumentation docs/ Anleitung mit Screenshots, API, Design-Dokumentation
``` ```
### Automatisierung
Unter `.gitea/workflows/` liegen zwei Abläufe für Gitea Actions: `ci.yml`
prüft bei jedem Push Typen, Tests und Produktionsbuild gegen eine echte
PostgreSQL-Instanz, `release.yml` legt beim Pushen eines Versions-Tags das
Release an — mit den Notizen aus `releases/vX.Y.Z.md`.
## Hintergrundlauf ## Hintergrundlauf
Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue
+1 -1
View File
@@ -40,7 +40,7 @@ attempt();
' '
echo "Flimmer: spiele Migrationen ein ..." echo "Flimmer: spiele Migrationen ein ..."
node node_modules/prisma/build/index.js migrate deploy node /node_modules/prisma/build/index.js migrate deploy
echo "Flimmer: starte Anwendung." echo "Flimmer: starte Anwendung."
exec "$@" exec "$@"
+9
View File
@@ -0,0 +1,9 @@
{
"name": "flimmer-prisma-cli",
"private": true,
"description": "Nur zum Installieren der Prisma-CLI ins Laufzeitimage. npm blockt Install-Skripte seit Version 11; ohne die Freigabe hier fehlt im Container die plattformpassende Engine. Bei einem Prisma-Update auch die Version in package.json anpassen.",
"allowScripts": {
"prisma@7.10.0": true,
"@prisma/engines@7.10.0": true
}
}
+56 -12
View File
@@ -17,18 +17,20 @@ die echten.
2. [Die Übersicht](#die-übersicht) 2. [Die Übersicht](#die-übersicht)
3. [Titel finden und aufnehmen](#titel-finden-und-aufnehmen) 3. [Titel finden und aufnehmen](#titel-finden-und-aufnehmen)
4. [Eine Serie verfolgen](#eine-serie-verfolgen) 4. [Eine Serie verfolgen](#eine-serie-verfolgen)
5. [Die Bibliothek](#die-bibliothek) 5. [Hab ich — die eigene Sammlung](#hab-ich--die-eigene-sammlung)
6. [Universen](#universen) 6. [Die Bibliothek](#die-bibliothek)
7. [Kalender](#kalender) 7. [Universen](#universen)
8. [Statistik](#statistik) 8. [Kalender](#kalender)
9. [Einstellungen](#einstellungen) 9. [Statistik](#statistik)
10. [Benachrichtigungen](#benachrichtigungen) 10. [Einstellungen](#einstellungen)
11. [Import und Export](#import-und-export) 11. [Benachrichtigungen](#benachrichtigungen)
12. [API-Schlüssel](#api-schlüssel) 12. [Import und Export](#import-und-export)
13. [Papierkorb](#papierkorb) 13. [API-Schlüssel](#api-schlüssel)
14. [Verwaltung](#verwaltung) 14. [Papierkorb](#papierkorb)
15. [Auf dem Handy](#auf-dem-handy) 15. [Verwaltung](#verwaltung)
16. [Helles Farbschema](#helles-farbschema) 16. [Öffentliches Profil](#öffentliches-profil)
17. [Auf dem Handy](#auf-dem-handy)
18. [Helles Farbschema](#helles-farbschema)
--- ---
@@ -114,6 +116,34 @@ Karte zeigt dann „2. Durchlauf".
--- ---
## Hab ich — die eigene Sammlung
![Sammlung](screenshots/20-sammlung.webp)
„Wo läuft das?" beantwortet nur die halbe Frage. Manche Filme laufen nirgends
mehr — die hat man gekauft und liegen bei sich zu Hause, in Jellyfin, Plex,
Emby, auf dem NAS oder als Scheibe im Regal.
Dafür gibt es das Häkchen **Hab ich** auf der Detailseite, rechts über dem Feld
„wo schaue ich das". Daneben trägst du ein, *wo* der Titel liegt; das Feld
schlägt die gängigen Möglichkeiten vor, nimmt aber auch alles andere an.
Was das bewirkt:
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit —
was du hast, ist die verlässlichere Antwort.
- Poster in der Bibliothek bekommen ein Abzeichen, und statt des
Streamingdienstes steht dort deine Quelle.
- In der Filterleiste gibt es einen eigenen Knopf **Hab ich** samt Zähler.
- Export und Import führen die Angabe mit (Spalten `HabIch` und `Sammlung`),
ebenso die REST-API.
Bewusst getrennt vom Feld „wo schaue ich das": Das eine sagt, wo ein Titel
gerade läuft — das ändert sich ständig. Das andere sagt, was dir gehört, und
das bleibt.
---
## Die Bibliothek ## Die Bibliothek
![Bibliothek](screenshots/05-bibliothek.webp) ![Bibliothek](screenshots/05-bibliothek.webp)
@@ -320,6 +350,20 @@ sperren.
--- ---
## Öffentliches Profil
![Öffentliches Profil](screenshots/19-profil.webp)
Wer mag, kann eine schlichte Seite unter `/u/<adresse>` freischalten — mit
Schauzeit, Zahlen, zuletzt Gesehenem und den Listen „Die meiste Zeit" und
„Bestbewertet".
Was dort **nicht** steht: Notizen, Watchlist, E-Mail-Adresse, Tags. Die Seite
braucht zwei Freigaben — deine in den Einstellungen und die des Betreibers in
der Verwaltung. Fehlt eine davon, ist die Adresse schlicht nicht erreichbar.
---
## Auf dem Handy ## Auf dem Handy
![Auf dem Handy](screenshots/17-mobil-uebersicht.webp) ![Auf dem Handy](screenshots/17-mobil-uebersicht.webp)
+14 -3
View File
@@ -57,8 +57,9 @@ GET /api/v1/bibliothek?status=WATCHING&typ=SERIES&genre=Drama&q=nord&limit=50&o
POST /api/v1/bibliothek POST /api/v1/bibliothek
``` ```
Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `limit`, `offset`. Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `sammlung`, `limit`,
Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`. `offset`. Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
`sammlung=1` liefert nur Titel, die in der eigenen Sammlung liegen.
Aufnehmen — entweder mit interner Kennung oder direkt mit der TMDB-Kennung: Aufnehmen — entweder mit interner Kennung oder direkt mit der TMDB-Kennung:
@@ -75,7 +76,9 @@ DELETE /api/v1/bibliothek/{entryId}
``` ```
`GET` liefert den Eintrag samt `progress` und `nextEpisode`. `GET` liefert den Eintrag samt `progress` und `nextEpisode`.
`PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite`. `PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite`
sowie `owned` (liegt der Titel in der eigenen Sammlung?) und `ownedSource`
(wo genau — Jellyfin, Plex, Blu-ray …).
`DELETE` legt den Eintrag in den Papierkorb — dort bleibt er 30 Tage. `DELETE` legt den Eintrag in den Papierkorb — dort bleibt er 30 Tage.
### Sichtungen ### Sichtungen
@@ -161,6 +164,14 @@ GET /api/v1/export?format=csv
JSON enthält zusätzlich alle Sichtungen und Universen. JSON enthält zusätzlich alle Sichtungen und Universen.
## Beispiel: was ich habe, aber noch nicht gesehen habe
```bash
curl -s -H "Authorization: Bearer $KEY" \
"$HOST/api/v1/bibliothek?sammlung=1&status=WATCHLIST" \
| jq -r '.items[] | "\(.title) — liegt bei: \(.ownedSource // "ohne Angabe")"'
```
## Beispiel: Serien mit offener Episode auflisten ## Beispiel: Serien mit offener Episode auflisten
```bash ```bash
+32 -3
View File
@@ -103,6 +103,30 @@ denselben Variablennamen — keine Änderung an Komponenten.
| `.fl-progress` | Fortschrittsbalken im Signaturverlauf | | `.fl-progress` | Fortschrittsbalken im Signaturverlauf |
| `.fl-glimmer` | Mikro-Animation beim Abhaken einer Episode | | `.fl-glimmer` | Mikro-Animation beim Abhaken einer Episode |
## Diagrammfarben
Die Signaturfarben sind für Diagrammflächen auf dunklem Grund zu hell. Für die
beiden Serien der Statistik stehen deshalb abgedunkelte Varianten:
| Serie | Wert | Prüfung |
|---|---|---|
| Episoden | `#c47a18` | Helligkeitsband, Sättigung, Kontrast ≥ 3:1 — in **beiden** Modi |
| Filme | `#d92d78` | dito |
Das schlechteste Paar erreicht ΔE 12,1 unter Deuteranopie und 20,6 bei
normalem Sehen (Ziel: ≥ 8 bzw. ≥ 15). Beide Werte gelten auf hellem wie
dunklem Grund, weshalb es nur **eine** Diagrammpalette gibt statt zweier.
Balken ohne Identitätsbedeutung (Genre-Verteilung) tragen einheitlich
`#c47a18` — die Länge zeigt schon die Größe, die Farbe muss nichts
wiederholen. Streamingdienste tragen ihre Markenfarbe, aber nur, wenn sie auf
hellem *und* dunklem Grund mindestens 3:1 erreicht; sonst fällt der Balken auf
den geprüften Standardton zurück.
Die Aussage hängt nie allein an der Farbe: Legende, Achsenwerte, ein Tooltip
beim Überfahren und eine für Screenreader hinterlegte Tabelle tragen dieselbe
Information.
## Bewegung ## Bewegung
Ruhig und kurz: 200–420 ms, `cubic-bezier(0.22, 1, 0.36, 1)`. Keine harten Ruhig und kurz: 200–420 ms, `cubic-bezier(0.22, 1, 0.36, 1)`. Keine harten
@@ -111,9 +135,14 @@ verklingt. `prefers-reduced-motion: reduce` schaltet sämtliche Übergänge ab.
## Typografie ## Typografie
**Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Wird beim Bauen **Outfit** — geometrisch, leicht gerundet, passt zur Wortmarke. Die variable
heruntergeladen und mit ausgeliefert; die laufende Instanz fragt nie bei Google Fassung (Strichstärke 100–900) liegt als Datei im Repository
an. Rückfallkette: Inter, dann System-Sans. (`public/fonts/`), aufgeteilt in Latin und Latin-Extended.
Bewusst nicht `next/font/google`: das lädt die Schrift beim Bauen herunter und
macht den Build von einer Netzverbindung abhängig — im Container blieb er
daran hängen. Mit der mitgelieferten Datei gibt es weder beim Bauen noch zur
Laufzeit einen Abruf nach außen. Rückfallkette: Inter, dann System-Sans.
## Grundhaltung ## Grundhaltung
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+2 -6
View File
@@ -14,12 +14,8 @@ const nextConfig: NextConfig = {
}, },
poweredByHeader: false, poweredByHeader: false,
serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"], serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"],
// Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über // Bilder laufen über den eigenen Cache-Endpunkt (/api/image), nicht über
// next/image-Remote-Loader — deshalb hier keine remotePatterns nötig. // next/image — deshalb ist hier nichts zu konfigurieren.
images: {
dangerouslyAllowSVG: true,
contentDispositionType: "attachment",
},
async headers() { async headers() {
return [ return [
{ {
+2 -14
View File
@@ -1,12 +1,12 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.1.0", "version": "0.6.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flimmer", "name": "flimmer",
"version": "0.1.0", "version": "0.6.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
@@ -25,7 +25,6 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "4.3.3", "@tailwindcss/postcss": "4.3.3",
"@types/bcryptjs": "3.0.0",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/nodemailer": "8.0.1", "@types/nodemailer": "8.0.1",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
@@ -1945,17 +1944,6 @@
"tailwindcss": "4.3.3" "tailwindcss": "4.3.3"
} }
}, },
"node_modules/@types/bcryptjs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"bcryptjs": "*"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.6.0", "version": "0.7.0",
"private": true, "private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT", "license": "MIT",
@@ -36,7 +36,6 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "4.3.3", "@tailwindcss/postcss": "4.3.3",
"@types/bcryptjs": "3.0.0",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/nodemailer": "8.0.1", "@types/nodemailer": "8.0.1",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
@@ -51,5 +50,9 @@
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"
},
"allowScripts": {
"prisma@7.10.0": true,
"@prisma/engines@7.10.0": true
} }
} }
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "LibraryEntry" ADD COLUMN "owned" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "ownedSource" TEXT;
-- CreateIndex
CREATE INDEX "LibraryEntry_userId_owned_idx" ON "LibraryEntry"("userId", "owned");
+8
View File
@@ -289,6 +289,13 @@ model LibraryEntry {
/// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar /// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar
whereToWatch String? whereToWatch String?
/// Liegt der Titel in der eigenen Sammlung? Unabhängig davon, ob ihn gerade
/// ein Streamingdienst führt — gekaufte Filme laufen irgendwann nirgends mehr.
owned Boolean @default(false)
/// Wo genau: „Jellyfin", „Blu-ray", „NAS", „Plex" …
ownedSource String?
favorite Boolean @default(false) favorite Boolean @default(false)
/// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch … /// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch …
@@ -309,6 +316,7 @@ model LibraryEntry {
@@unique([userId, titleId]) @@unique([userId, titleId])
@@index([userId, status]) @@index([userId, status])
@@index([userId, updatedAt]) @@index([userId, updatedAt])
@@index([userId, owned])
@@index([deletedAt]) @@index([deletedAt])
} }
+10 -3
View File
@@ -244,6 +244,9 @@ type EntryPlan = {
startedDaysAgo?: number; startedDaysAgo?: number;
/** Abstand zwischen zwei Episoden in Tagen. */ /** Abstand zwischen zwei Episoden in Tagen. */
spacingDays?: number; spacingDays?: number;
/** Liegt der Titel in der eigenen Sammlung? */
owned?: boolean;
ownedSource?: string;
}; };
const ENTRY_PLANS: EntryPlan[] = [ const ENTRY_PLANS: EntryPlan[] = [
@@ -315,8 +318,8 @@ const ENTRY_PLANS: EntryPlan[] = [
}, },
{ key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] }, { key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] },
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340 }, { key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340, owned: true, ownedSource: "Jellyfin" },
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320 }, { key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320, owned: true, ownedSource: "Jellyfin" },
{ key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 }, { key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 },
{ key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] }, { key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] },
{ key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] }, { key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] },
@@ -330,7 +333,7 @@ const ENTRY_PLANS: EntryPlan[] = [
startedDaysAgo: 180, startedDaysAgo: 180,
}, },
{ key: "stille-bilder-2", status: "WATCHLIST" }, { key: "stille-bilder-2", status: "WATCHLIST" },
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60 }, { key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60, owned: true, ownedSource: "Blu-ray" },
{ key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 }, { key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 },
{ key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 }, { key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 },
]; ];
@@ -355,6 +358,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
rating: plan.rating ?? null, rating: plan.rating ?? null,
notes: plan.notes ?? null, notes: plan.notes ?? null,
favorite: plan.favorite ?? false, favorite: plan.favorite ?? false,
owned: plan.owned ?? false,
ownedSource: plan.ownedSource ?? null,
currentRun, currentRun,
startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null, startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null,
finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null, finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null,
@@ -364,6 +369,8 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
rating: plan.rating ?? null, rating: plan.rating ?? null,
notes: plan.notes ?? null, notes: plan.notes ?? null,
favorite: plan.favorite ?? false, favorite: plan.favorite ?? false,
owned: plan.owned ?? false,
ownedSource: plan.ownedSource ?? null,
currentRun, currentRun,
}, },
}); });
Binary file not shown.
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
# Flimmer 0.7.0
„Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
gestartet, und dabei kamen Fehler ans Licht, die nur der echte Containerstart
zeigt.
### Hinzugefügt
- **Eigene Sammlung**: Jeder Eintrag lässt sich als „hab ich" markieren, mit
Angabe wo — Jellyfin, Plex, Emby, NAS, Blu-ray, DVD oder frei getippt.
Bewusst getrennt vom Feld „wo schaue ich das": Streaming-Verfügbarkeit kommt
und geht, der gekaufte Film auf dem Heimserver bleibt. Gerade die Titel, die
nirgends mehr laufen, will man ja wiederfinden.
- Abzeichen auf der Posterkachel und im Kopf der Detailseite
- Auf der Detailseite steht die Sammlung **vor** der Streaming-Verfügbarkeit
— was man hat, ist die verlässlichere Antwort auf „wo kann ich das schauen"
- Eigener Filter in der Bibliothek samt Zähler
- In Export (CSV und JSON), CSV-Import und REST-API (`?sammlung=1` beim
Lesen, `owned` und `ownedSource` beim Ändern)
### Behoben
- **Der Container konnte seine Migrationen nicht einspielen.** Das Laufzeitimage
bekam die Prisma-CLI, indem einzelne Ordner aus `node_modules` kopiert wurden
— dabei fehlten transitive Abhängigkeiten von `@prisma/config` (`effect` und
weitere). Die CLI wird jetzt in einer eigenen Build-Stufe vollständig
installiert und liegt im Laufzeitimage unter `/node_modules`, wo sie den
getracten Abhängigkeitsbaum der Anwendung nicht stört.
- **Der Build hing im Container am Schriftabruf.** `next/font/google` lädt
Outfit beim Bauen von Google herunter — das macht den Build von einer
Netzverbindung abhängig und blieb im Container hängen. Die variable Fassung
liegt jetzt als Datei im Repository; weder beim Bauen noch zur Laufzeit geht
eine Anfrage nach außen. Für eine selbst gehostete Anwendung ist das ohnehin
das Richtige.
- **npm blockierte Prismas Install-Skripte** (Voreinstellung seit npm 11),
wodurch im Image die plattformpassende Engine fehlen konnte. Sie sind jetzt
gezielt für `prisma` und `@prisma/engines` erlaubt statt pauschal.
### Hinzugefügt
- **Erscheinungsmeldung für Watchlist-Filme.** Der Schalter dafür stand schon im
Profil, verschickt wurde bisher nichts. Betrachtet wird ein Zeitfenster der
letzten Tage statt eines Zustandswechsels — TMDB verschiebt Termine
gelegentlich, und ein einmal verpasstes Datum soll die Meldung nicht für
immer verhindern.
- **Gitea-Actions-Workflows**: `ci.yml` prüft bei jedem Push Typen, Tests und
Produktionsbuild gegen eine echte PostgreSQL-Instanz; `release.yml` legt beim
Pushen eines Versions-Tags das Release mit den Notizen aus
`releases/vX.Y.Z.md` an.
- Diagrammfarben und ihre Prüfwerte in `docs/DESIGN.md` festgehalten; das
öffentliche Profil in die Anleitung aufgenommen.
### Entfernt
- Die ungenutzte `images`-Konfiguration in `next.config.ts`. `next/image` kommt
nirgends zum Einsatz — damit entfällt auch das sicherheitsrelevant aussehende
`dangerouslyAllowSVG`, das ohnehin wirkungslos war.
+2
View File
@@ -114,6 +114,8 @@ const detailsSchema = z.object({
notes: z.string().max(5000).nullable().optional(), notes: z.string().max(5000).nullable().optional(),
whereToWatch: z.string().max(120).nullable().optional(), whereToWatch: z.string().max(120).nullable().optional(),
favorite: z.boolean().optional(), favorite: z.boolean().optional(),
owned: z.boolean().optional(),
ownedSource: z.string().max(80).nullable().optional(),
}); });
export async function updateEntryAction(entryId: string, data: unknown) { export async function updateEntryAction(entryId: string, data: unknown) {
@@ -36,6 +36,8 @@ export const GET = withApi(async (_request, user, context) => {
rating: entry.rating, rating: entry.rating,
notes: entry.notes, notes: entry.notes,
whereToWatch: entry.whereToWatch, whereToWatch: entry.whereToWatch,
owned: entry.owned,
ownedSource: entry.ownedSource,
favorite: entry.favorite, favorite: entry.favorite,
currentRun: entry.currentRun, currentRun: entry.currentRun,
tags: entry.tags.map((link) => link.tag.name), tags: entry.tags.map((link) => link.tag.name),
@@ -58,6 +60,8 @@ const patchSchema = z.object({
notes: z.string().max(5000).nullable().optional(), notes: z.string().max(5000).nullable().optional(),
whereToWatch: z.string().max(120).nullable().optional(), whereToWatch: z.string().max(120).nullable().optional(),
favorite: z.boolean().optional(), favorite: z.boolean().optional(),
owned: z.boolean().optional(),
ownedSource: z.string().max(80).nullable().optional(),
}); });
/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */ /** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */
+1
View File
@@ -22,6 +22,7 @@ export const GET = withApi(async (request, user) => {
genre: url.searchParams.get("genre") ?? undefined, genre: url.searchParams.get("genre") ?? undefined,
tag: url.searchParams.get("tag") ?? undefined, tag: url.searchParams.get("tag") ?? undefined,
provider: url.searchParams.get("dienst") ?? undefined, provider: url.searchParams.get("dienst") ?? undefined,
owned: url.searchParams.get("sammlung") === "1" ? true : undefined,
}); });
return { return {
+15
View File
@@ -73,6 +73,12 @@ export function GET() {
{ name: "genre", in: "query", schema: { type: "string" } }, { name: "genre", in: "query", schema: { type: "string" } },
{ name: "tag", in: "query", schema: { type: "string" } }, { name: "tag", in: "query", schema: { type: "string" } },
{ name: "dienst", in: "query", schema: { type: "string" } }, { name: "dienst", in: "query", schema: { type: "string" } },
{
name: "sammlung",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "Nur Titel, die in der eigenen Sammlung liegen.",
},
{ name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } }, { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } },
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } }, { name: "offset", in: "query", schema: { type: "integer", default: 0 } },
], ],
@@ -123,6 +129,15 @@ export function GET() {
notes: { type: "string", nullable: true }, notes: { type: "string", nullable: true },
whereToWatch: { type: "string", nullable: true }, whereToWatch: { type: "string", nullable: true },
favorite: { type: "boolean" }, favorite: { type: "boolean" },
owned: {
type: "boolean",
description: "Liegt der Titel in der eigenen Sammlung?",
},
ownedSource: {
type: "string",
nullable: true,
description: "Wo genau — Jellyfin, Plex, Blu-ray …",
},
}, },
}, },
}, },
+1
View File
@@ -29,6 +29,7 @@ export default async function LibraryPage({
genre: params.genre || undefined, genre: params.genre || undefined,
tag: params.tag || undefined, tag: params.tag || undefined,
provider: params.dienst || undefined, provider: params.dienst || undefined,
owned: params.sammlung === "1" ? true : undefined,
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined, sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
}; };
+16 -5
View File
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Outfit } from "next/font/google"; import localFont from "next/font/local";
import { BrandDefs } from "@/components/brand/FlimmerLogo"; import { BrandDefs } from "@/components/brand/FlimmerLogo";
import { ToastProvider } from "@/components/Toasts"; import { ToastProvider } from "@/components/Toasts";
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration"; import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
@@ -7,13 +7,24 @@ import { env } from "@/lib/env";
import "./globals.css"; import "./globals.css";
/** /**
* Outfit wird beim Bauen heruntergeladen und mit ausgeliefert — die laufende * Outfit liegt als Datei im Repository.
* Instanz fragt also niemals bei Google an. *
* next/font/google würde die Schrift beim Bauen herunterladen — das macht den
* Build von einer Netzverbindung abhängig und ließ ihn im Container hängen.
* Für eine selbst gehostete Anwendung ist die mitgelieferte Datei ohnehin das
* Richtige: kein Abruf beim Bauen, keiner zur Laufzeit.
*
* Es ist die variable Fassung (Strichstärke 100–900), aufgeteilt in Latin und
* Latin-Extended — der Browser lädt nur, was die Seite wirklich braucht.
*/ */
const outfit = Outfit({ const outfit = localFont({
subsets: ["latin"], src: [
{ path: "../../public/fonts/outfit-latin.woff2", weight: "100 900", style: "normal" },
{ path: "../../public/fonts/outfit-latin-ext.woff2", weight: "100 900", style: "normal" },
],
display: "swap", display: "swap",
variable: "--font-outfit", variable: "--font-outfit",
fallback: ["Inter", "Segoe UI", "system-ui", "sans-serif"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
+45
View File
@@ -120,6 +120,19 @@ export default async function TitlePage({ params }: { params: Params }) {
{year ? ` · ${year}` : ""} {year ? ` · ${year}` : ""}
{title.status ? ` · ${title.status}` : ""} {title.status ? ` · ${title.status}` : ""}
</p> </p>
{entry?.owned && (
<span
className="rounded-full px-2.5 py-0.5 text-xs font-medium text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
title={
entry.ownedSource
? `In deiner Sammlung: ${entry.ownedSource}`
: "In deiner Sammlung"
}
>
Hab ich{entry.ownedSource ? ` · ${entry.ownedSource}` : ""}
</span>
)}
{entry && ( {entry && (
<span <span
className="rounded-full px-2.5 py-0.5 text-xs font-medium" className="rounded-full px-2.5 py-0.5 text-xs font-medium"
@@ -225,6 +238,7 @@ export default async function TitlePage({ params }: { params: Params }) {
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} /> <SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
) : ( ) : (
<div className="fl-card p-4"> <div className="fl-card p-4">
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
<ProviderList groups={providerGroups} region={user.region} /> <ProviderList groups={providerGroups} region={user.region} />
</div> </div>
)} )}
@@ -245,6 +259,8 @@ export default async function TitlePage({ params }: { params: Params }) {
whereToWatch={entry.whereToWatch} whereToWatch={entry.whereToWatch}
tags={entry.tags.map((link) => link.tag.name)} tags={entry.tags.map((link) => link.tag.name)}
suggestion={suggestion} suggestion={suggestion}
owned={entry.owned}
ownedSource={entry.ownedSource}
/> />
</div> </div>
@@ -253,6 +269,7 @@ export default async function TitlePage({ params }: { params: Params }) {
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]"> <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Wo läuft das? Wo läuft das?
</h2> </h2>
<OwnedNotice owned={entry.owned} source={entry.ownedSource} />
<ProviderList groups={providerGroups} region={user.region} /> <ProviderList groups={providerGroups} region={user.region} />
</div> </div>
)} )}
@@ -299,6 +316,34 @@ export default async function TitlePage({ params }: { params: Params }) {
); );
} }
/**
* Hinweis auf die eigene Sammlung — steht bewusst **vor** der
* Streaming-Verfügbarkeit: was man hat, ist die verlässlichere Antwort auf
* „wo kann ich das schauen".
*/
function OwnedNotice({ owned, source }: { owned: boolean; source: string | null }) {
if (!owned) return null;
return (
<p
className="mb-3 flex items-center gap-2 rounded-xl border px-3 py-2 text-sm"
style={{
borderColor: "color-mix(in oklab, var(--fl-success) 45%, transparent)",
backgroundColor: "color-mix(in oklab, var(--fl-success) 10%, transparent)",
color: "var(--fl-success)",
}}
>
<svg viewBox="0 0 24 24" className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>
In deiner Sammlung
{source ? <span className="font-medium"> · {source}</span> : null}
</span>
</p>
);
}
function Fact({ label, children }: { label: string; children: React.ReactNode }) { function Fact({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div> <div>
+95 -1
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useEffect, useRef, useState, useTransition } from "react"; import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import { setTagsAction, updateEntryAction } from "@/app/actions/library"; import { setTagsAction, updateEntryAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts"; import { useToasts } from "@/components/Toasts";
@@ -17,6 +18,8 @@ export function EntryNotes({
whereToWatch, whereToWatch,
tags, tags,
suggestion, suggestion,
owned,
ownedSource,
}: { }: {
entryId: string; entryId: string;
notes: string | null; notes: string | null;
@@ -24,9 +27,12 @@ export function EntryNotes({
tags: string[]; tags: string[];
/** Vorschlag aus der Streaming-Verfügbarkeit. */ /** Vorschlag aus der Streaming-Verfügbarkeit. */
suggestion: string | null; suggestion: string | null;
owned: boolean;
ownedSource: string | null;
}) { }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<OwnedEditor entryId={entryId} owned={owned} source={ownedSource} />
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} /> <WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
<TagEditor entryId={entryId} tags={tags} /> <TagEditor entryId={entryId} tags={tags} />
<Notes entryId={entryId} value={notes} /> <Notes entryId={entryId} value={notes} />
@@ -34,6 +40,94 @@ export function EntryNotes({
); );
} }
/**
* „Hab ich" — der Titel liegt in der eigenen Sammlung.
*
* Bewusst getrennt von „wo schaue ich das": Streaming-Verfügbarkeit kommt und
* geht, der gekaufte Film auf dem Heimserver bleibt. Genau die Titel, die
* nirgends mehr laufen, will man ja wiederfinden.
*/
function OwnedEditor({
entryId,
owned,
source,
}: {
entryId: string;
owned: boolean;
source: string | null;
}) {
const save = useAutosave(entryId);
const [isOwned, setIsOwned] = useState(owned);
const [text, setText] = useState(source ?? "");
const initial = useRef(source ?? "");
return (
<div className="space-y-2 rounded-xl border border-[var(--fl-border)] p-3">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={isOwned}
onChange={(event) => {
setIsOwned(event.target.checked);
save({ owned: event.target.checked });
}}
className="sr-only"
/>
<span
aria-hidden
className={clsx(
"mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-all duration-200",
isOwned ? "border-transparent" : "border-[var(--fl-border-strong)]",
)}
style={isOwned ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{isOwned && (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="var(--fl-on-accent)" strokeWidth="3.5">
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">Hab ich</span>
<span className="block text-xs text-[var(--fl-faint)]">
Liegt in der eigenen Sammlung — unabhängig davon, ob es gerade irgendwo läuft.
</span>
</span>
</label>
{isOwned && (
<div className="pl-8">
<label htmlFor="ownedSource" className="sr-only">
Wo liegt der Titel?
</label>
<input
id="ownedSource"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ ownedSource: text });
}}
list="fl-owned-quellen"
placeholder="Jellyfin, Plex, NAS, Blu-ray …"
className="fl-input py-1.5 text-sm"
/>
<datalist id="fl-owned-quellen">
<option value="Jellyfin" />
<option value="Plex" />
<option value="Emby" />
<option value="NAS" />
<option value="Blu-ray" />
<option value="DVD" />
<option value="Gekauft (digital)" />
</datalist>
</div>
)}
</div>
);
}
function useAutosave(entryId: string) { function useAutosave(entryId: string) {
const { report } = useToasts(); const { report } = useToasts();
const router = useRouter(); const router = useRouter();
+30 -10
View File
@@ -19,15 +19,31 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
<div className="relative"> <div className="relative">
<Poster path={item.posterPath} alt={item.title} size="w342" /> <Poster path={item.posterPath} alt={item.title} size="w342" />
<span {/* Status und Sammlung teilen sich die obere Zeile — unten sitzt bei
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md" den Postern oft der Titel, dort würden sie ihn verdecken. */}
style={{ <span className="absolute left-2 top-2 flex flex-wrap gap-1">
color: STATUS_COLOR[status], <span
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`, className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`, style={{
}} color: STATUS_COLOR[status],
> backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
{STATUS_LABEL[status]} boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
}}
>
{STATUS_LABEL[status]}
</span>
{item.owned && (
<span
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
title={
item.ownedSource ? `In deiner Sammlung: ${item.ownedSource}` : "In deiner Sammlung"
}
>
Hab ich
</span>
)}
</span> </span>
{item.rating != null && ( {item.rating != null && (
@@ -51,7 +67,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
<p className="text-xs text-[var(--fl-faint)]"> <p className="text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"} {item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""} {item.year ? ` · ${item.year}` : ""}
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""} {item.owned && item.ownedSource
? ` · ${item.ownedSource}`
: item.whereToWatch
? ` · ${item.whereToWatch}`
: ""}
</p> </p>
{showProgress && ( {showProgress && (
+12 -1
View File
@@ -56,7 +56,9 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const status = params.get("status"); const status = params.get("status");
const type = params.get("typ"); const type = params.get("typ");
const sort = params.get("sortierung") ?? "aktualisiert"; const sort = params.get("sortierung") ?? "aktualisiert";
const activeCount = ["genre", "tag", "dienst", "typ"].filter((key) => params.get(key)).length; const activeCount = ["genre", "tag", "dienst", "typ", "sammlung"].filter((key) =>
params.get(key),
).length;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -78,6 +80,15 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
{STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} /> {STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} />
</Chip> </Chip>
))} ))}
{facets.owned > 0 && (
<Chip
active={params.get("sammlung") === "1"}
onClick={() => apply({ sammlung: params.get("sammlung") === "1" ? null : "1" })}
>
Hab ich <Count value={facets.owned} />
</Chip>
)}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
+12 -1
View File
@@ -110,7 +110,14 @@ export async function setStatus(userId: string, entryId: string, status: EntrySt
export async function updateEntryDetails( export async function updateEntryDetails(
userId: string, userId: string,
entryId: string, entryId: string,
data: { rating?: number | null; notes?: string | null; whereToWatch?: string | null; favorite?: boolean }, data: {
rating?: number | null;
notes?: string | null;
whereToWatch?: string | null;
favorite?: boolean;
owned?: boolean;
ownedSource?: string | null;
},
) { ) {
await requireEntry(userId, entryId); await requireEntry(userId, entryId);
@@ -127,6 +134,10 @@ export async function updateEntryDetails(
? { whereToWatch: data.whereToWatch?.trim() || null } ? { whereToWatch: data.whereToWatch?.trim() || null }
: {}), : {}),
...(data.favorite !== undefined ? { favorite: data.favorite } : {}), ...(data.favorite !== undefined ? { favorite: data.favorite } : {}),
...(data.owned !== undefined ? { owned: data.owned } : {}),
...(data.ownedSource !== undefined
? { ownedSource: data.ownedSource?.trim() || null }
: {}),
}, },
}); });
} }
+12 -1
View File
@@ -19,6 +19,8 @@ export type LibraryFilters = {
genre?: string; genre?: string;
tag?: string; tag?: string;
provider?: string; provider?: string;
/** Nur Titel, die in der eigenen Sammlung liegen. */
owned?: boolean;
sort?: LibrarySort; sort?: LibrarySort;
}; };
@@ -42,6 +44,8 @@ export type LibraryItem = {
favorite: boolean; favorite: boolean;
currentRun: number; currentRun: number;
whereToWatch: string | null; whereToWatch: string | null;
owned: boolean;
ownedSource: string | null;
tags: string[]; tags: string[];
watched: number; watched: number;
total: number; total: number;
@@ -56,6 +60,7 @@ export async function getLibrary(
userId, userId,
deletedAt: null, deletedAt: null,
...(filters.status ? { status: filters.status } : {}), ...(filters.status ? { status: filters.status } : {}),
...(filters.owned ? { owned: true } : {}),
...(filters.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}), ...(filters.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}),
title: { title: {
...(filters.type ? { type: filters.type } : {}), ...(filters.type ? { type: filters.type } : {}),
@@ -116,6 +121,8 @@ export async function getLibrary(
favorite: entry.favorite, favorite: entry.favorite,
currentRun: entry.currentRun, currentRun: entry.currentRun,
whereToWatch: entry.whereToWatch, whereToWatch: entry.whereToWatch,
owned: entry.owned,
ownedSource: entry.ownedSource,
tags: entry.tags.map((link) => link.tag.name), tags: entry.tags.map((link) => link.tag.name),
watched, watched,
total, total,
@@ -162,11 +169,13 @@ export type LibraryFacets = {
tags: string[]; tags: string[];
providers: string[]; providers: string[];
counts: Record<EntryStatus | "ALLE", number>; counts: Record<EntryStatus | "ALLE", number>;
/** Wie viele Titel in der eigenen Sammlung liegen. */
owned: number;
}; };
/** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */ /** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */
export async function getLibraryFacets(userId: string): Promise<LibraryFacets> { export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
const [genreRows, tagRows, providerRows, statusRows, total] = await Promise.all([ const [genreRows, tagRows, providerRows, statusRows, total, owned] = await Promise.all([
prisma.$queryRaw<{ genre: string }[]>` prisma.$queryRaw<{ genre: string }[]>`
SELECT DISTINCT unnest(t."genres") AS genre SELECT DISTINCT unnest(t."genres") AS genre
FROM "LibraryEntry" e FROM "LibraryEntry" e
@@ -193,6 +202,7 @@ export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
_count: { _all: true }, _count: { _all: true },
}), }),
prisma.libraryEntry.count({ where: { userId, deletedAt: null } }), prisma.libraryEntry.count({ where: { userId, deletedAt: null } }),
prisma.libraryEntry.count({ where: { userId, deletedAt: null, owned: true } }),
]); ]);
const counts = { const counts = {
@@ -211,6 +221,7 @@ export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
tags: tagRows.map((row) => row.name), tags: tagRows.map((row) => row.name),
providers: providerRows.map((row) => row.name), providers: providerRows.map((row) => row.name),
counts, counts,
owned,
}; };
} }
+60
View File
@@ -151,6 +151,56 @@ async function notifyAvailability(titleId: string, titleName: string): Promise<n
return sent; return sent;
} }
/**
* Meldet Filme von der Watchlist, die gerade erschienen sind.
*
* Betrachtet wird ein Fenster der letzten Tage statt eines Zustandswechsels:
* TMDB verschiebt Termine gelegentlich, und ein einmal verpasstes Datum soll
* nicht dazu führen, dass die Meldung für immer ausbleibt. Doppelt kommt sie
* trotzdem nie — dafür sorgt der Dedupe-Schlüssel.
*/
async function notifyReleases(windowDays = 7): Promise<number> {
const from = new Date(Date.now() - windowDays * 86_400_000);
const to = new Date();
const titles = await prisma.title.findMany({
where: {
type: "MOVIE",
releaseDate: { gte: from, lte: to },
entries: { some: { deletedAt: null, status: "WATCHLIST" } },
},
select: { id: true, title: true, releaseDate: true },
});
let sent = 0;
for (const title of titles) {
const watchers = await prisma.libraryEntry.findMany({
where: {
titleId: title.id,
deletedAt: null,
status: "WATCHLIST",
user: { notifyRelease: true, isActive: true },
},
select: { userId: true },
});
for (const watcher of watchers) {
const result = await notifyUser(watcher.userId, {
subject: `Jetzt erschienen: ${title.title}`,
body: `„${title.title}" von deiner Watchlist ist seit dem ${title.releaseDate?.toLocaleDateString(
"de-DE",
{ day: "2-digit", month: "2-digit", year: "numeric" },
)} draußen.`,
path: `/titel/${title.id}`,
dedupeKey: `release:${title.id}`,
});
sent += result.sent;
}
}
return sent;
}
/** Räumt den Papierkorb und abgelaufene Sitzungen. */ /** Räumt den Papierkorb und abgelaufene Sitzungen. */
export async function purgeExpired(): Promise<{ entries: number; sessions: number }> { export async function purgeExpired(): Promise<{ entries: number; sessions: number }> {
const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 86_400_000); const cutoff = new Date(Date.now() - TRASH_RETENTION_DAYS * 86_400_000);
@@ -223,6 +273,16 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
stats.errors.push("Kein TMDB-Schlüssel hinterlegt — der Abgleich wurde übersprungen."); stats.errors.push("Kein TMDB-Schlüssel hinterlegt — der Abgleich wurde übersprungen.");
} }
// Erscheinungsmeldungen hängen nicht am TMDB-Abruf: sie stützen sich auf
// die Erscheinungsdaten, die schon im Katalog stehen.
try {
stats.notificationsSent += await notifyReleases();
} catch (error) {
stats.errors.push(
`Erscheinungsmeldungen: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
);
}
const purged = await purgeExpired(); const purged = await purgeExpired();
stats.entriesPurged = purged.entries; stats.entriesPurged = purged.entries;
stats.sessionsPurged = purged.sessions; stats.sessionsPurged = purged.sessions;
+18 -1
View File
@@ -135,6 +135,11 @@ const CSV_HEADERS: Record<string, string> = {
notiz: "notes", notiz: "notes",
notes: "notes", notes: "notes",
kommentar: "notes", kommentar: "notes",
habich: "owned",
owned: "owned",
besitz: "owned",
sammlung: "ownedSource",
quelle: "ownedSource",
}; };
/** /**
@@ -185,6 +190,8 @@ export async function importCsv(userId: string, text: string): Promise<ImportSum
rating: record.rating ? Number.parseInt(record.rating, 10) : null, rating: record.rating ? Number.parseInt(record.rating, 10) : null,
year: record.year ? Number.parseInt(record.year, 10) : null, year: record.year ? Number.parseInt(record.year, 10) : null,
notes: record.notes || null, notes: record.notes || null,
owned: /^(ja|yes|true|1|x)$/i.test(record.owned ?? ""),
ownedSource: record.ownedSource || null,
}); });
if (result === "added") summary.added += 1; if (result === "added") summary.added += 1;
else summary.skipped += 1; else summary.skipped += 1;
@@ -299,6 +306,8 @@ type ImportRecord = {
rating: number | null; rating: number | null;
year: number | null; year: number | null;
notes: string | null; notes: string | null;
owned?: boolean;
ownedSource?: string | null;
}; };
/** /**
@@ -353,12 +362,14 @@ async function importOne(userId: string, record: ImportRecord): Promise<"added"
const entry = await addToLibrary(userId, titleId, record.status); const entry = await addToLibrary(userId, titleId, record.status);
if (record.rating || record.notes) { if (record.rating || record.notes || record.owned || record.ownedSource) {
await prisma.libraryEntry.update({ await prisma.libraryEntry.update({
where: { id: entry.id }, where: { id: entry.id },
data: { data: {
rating: record.rating && record.rating >= 1 && record.rating <= 10 ? record.rating : null, rating: record.rating && record.rating >= 1 && record.rating <= 10 ? record.rating : null,
notes: record.notes, notes: record.notes,
owned: record.owned ?? false,
ownedSource: record.ownedSource ?? null,
}, },
}); });
} }
@@ -394,6 +405,8 @@ export async function exportCsv(userId: string): Promise<string> {
"Status", "Status",
"Bewertung", "Bewertung",
"Durchlaeufe", "Durchlaeufe",
"HabIch",
"Sammlung",
"WoGeschaut", "WoGeschaut",
"Tags", "Tags",
"Notiz", "Notiz",
@@ -410,6 +423,8 @@ export async function exportCsv(userId: string): Promise<string> {
entry.status, entry.status,
entry.rating ?? "", entry.rating ?? "",
entry.currentRun + 1, entry.currentRun + 1,
entry.owned ? "ja" : "nein",
entry.ownedSource ?? "",
entry.whereToWatch ?? "", entry.whereToWatch ?? "",
entry.tags.map((link) => link.tag.name).join(" "), entry.tags.map((link) => link.tag.name).join(" "),
entry.notes?.replace(/\r?\n/g, " ") ?? "", entry.notes?.replace(/\r?\n/g, " ") ?? "",
@@ -479,6 +494,8 @@ export async function exportJson(userId: string): Promise<object> {
rating: entry.rating, rating: entry.rating,
notes: entry.notes, notes: entry.notes,
whereToWatch: entry.whereToWatch, whereToWatch: entry.whereToWatch,
owned: entry.owned,
ownedSource: entry.ownedSource,
favorite: entry.favorite, favorite: entry.favorite,
currentRun: entry.currentRun, currentRun: entry.currentRun,
tags: entry.tags.map((link) => link.tag.name), tags: entry.tags.map((link) => link.tag.name),
+52
View File
@@ -336,3 +336,55 @@ test("fremde Einträge sind nicht erreichbar", async () => {
await prisma.user.delete({ where: { id: other.id } }); await prisma.user.delete({ where: { id: other.id } });
}); });
test("Sammlung ist unabhängig von der Streaming-Verfügbarkeit", async () => {
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
const fresh = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
assert.equal(fresh.owned, false, "standardmäßig gehört einem nichts");
assert.equal(fresh.ownedSource, null);
const owned = await updateEntryDetails(userId, entry.id, {
owned: true,
ownedSource: " Jellyfin ",
});
assert.equal(owned.owned, true);
assert.equal(owned.ownedSource, "Jellyfin", "Rand wird beschnitten");
// Der Fundort bleibt davon unberührt: das eine sagt, wo es läuft,
// das andere, wo es bei mir liegt.
assert.equal(
owned.whereToWatch,
"Disney Plus",
"der Vorschlag aus der Verfügbarkeit bleibt davon unberührt",
);
const released = await updateEntryDetails(userId, entry.id, {
owned: false,
ownedSource: "",
});
assert.equal(released.owned, false);
assert.equal(released.ownedSource, null, "leerer Text wird zu null");
});
test("Sammlung: Filter und Zähler in der Bibliothek", async () => {
const movie = await addToLibrary(userId, movieTitleId, "WATCHLIST");
const series = await addToLibrary(userId, seriesTitleId, "WATCHING");
await updateEntryDetails(userId, movie.id, { owned: true, ownedSource: "Blu-ray" });
const { getLibrary, getLibraryFacets } = await import("../src/lib/queries");
const alle = await getLibrary(userId, {});
assert.equal(alle.length, 2);
const nurSammlung = await getLibrary(userId, { owned: true });
assert.equal(nurSammlung.length, 1);
assert.equal(nurSammlung[0]?.entryId, movie.id);
assert.equal(nurSammlung[0]?.ownedSource, "Blu-ray");
const facets = await getLibraryFacets(userId);
assert.equal(facets.owned, 1, "der Zähler der Filterleiste stimmt");
assert.equal(facets.counts.ALLE, 2);
await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } });
});
+37 -1
View File
@@ -8,7 +8,7 @@ import { prisma } from "../src/lib/db";
import { importMovie, importSeries } from "../src/lib/catalog"; import { importMovie, importSeries } from "../src/lib/catalog";
import { addToLibrary, markMovieWatched, moveToTrash } from "../src/lib/library"; import { addToLibrary, markMovieWatched, moveToTrash } from "../src/lib/library";
import { exportCsv, exportJson, importCsv, importTrakt, parseCsv } from "../src/lib/transfer"; import { exportCsv, exportJson, importCsv, importTrakt, parseCsv } from "../src/lib/transfer";
import { purgeExpired, TRASH_RETENTION_DAYS } from "../src/lib/sync"; import { purgeExpired, runSync, TRASH_RETENTION_DAYS } from "../src/lib/sync";
import { notifyUser } from "../src/lib/notify"; import { notifyUser } from "../src/lib/notify";
/** Prüft Import, Export, das Aufräumen des Papierkorbs und den Doppelversandschutz. */ /** Prüft Import, Export, das Aufräumen des Papierkorbs und den Doppelversandschutz. */
@@ -239,3 +239,39 @@ test("dieselbe Nachricht wird nicht zweimal verschickt", async () => {
await prisma.notificationChannel.deleteMany({ where: { userId } }); await prisma.notificationChannel.deleteMany({ where: { userId } });
}); });
test("erschienene Watchlist-Filme werden gemeldet — und nur einmal", async () => {
// Der Film liegt auf der Watchlist und ist gestern erschienen.
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await prisma.title.update({
where: { id: movieTitleId },
data: { releaseDate: new Date(Date.now() - 86_400_000) },
});
await prisma.user.update({ where: { id: userId }, data: { notifyRelease: true } });
await prisma.notificationChannel.create({
data: {
userId,
kind: "WEBHOOK",
name: "Test",
config: { webhookUrl: "http://127.0.0.1:9/nicht-erreichbar" },
},
});
await runSync({ limit: 0 });
const first = await prisma.notificationLog.findMany({
where: { userId, dedupeKey: `release:${movieTitleId}` },
});
assert.equal(first.length, 1, "die Meldung wird angelegt");
assert.match(first[0]?.subject ?? "", /Jetzt erschienen/);
// Ein zweiter Lauf meldet dasselbe nicht noch einmal.
await runSync({ limit: 0 });
const second = await prisma.notificationLog.count({
where: { userId, dedupeKey: `release:${movieTitleId}` },
});
assert.equal(second, 1);
await prisma.notificationChannel.deleteMany({ where: { userId } });
await prisma.libraryEntry.delete({ where: { id: entry.id } });
});