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.
This commit is contained in:
Florian Wilde
2026-09-01 21:15:53 +02:00
parent cd9953c47c
commit 9ee8664233
26 changed files with 467 additions and 52 deletions
+21 -6
View File
@@ -7,11 +7,26 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.6.1] — 2026-09-01 ## [0.7.0] — 2026-09-01
Nacharbeiten aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich „Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
gebaut und gestartet — dabei kam ein Fehler ans Licht, den nur der echte aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
Containerstart zeigt. 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 ### Behoben
@@ -306,8 +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.1...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.7.0...HEAD
[0.6.1]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.1 [0.7.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.7.0
[0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0 [0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0 [0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
[0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0 [0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
+26 -2
View File
@@ -91,6 +91,18 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
--- ---
## Nach der Abnahme (v0.7.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 7.1 | 20:25 | 20:50 | 25 min | Docker-Image zum ersten Mal wirklich gebaut und gestartet; zwei Fehler behoben (fehlende Prisma-Abhängigkeiten im Laufzeitimage, Schriftabruf beim Bauen), Gitea-Workflows, Erscheinungsmeldung nachgezogen |
| 7.2 | 20:50 | 21:05 | 15 min | Compose-Stack von null verifiziert: Migration, erstes Konto wird Administrator, Verwaltung erreichbar |
| 7.3 | 21:05 | 21:16 | 11 min | Rückfrage aus dem Betrieb umgesetzt: „hab ich" für die eigene Sammlung (Jellyfin, Plex, NAS, Blu-ray) — Schema, Logik, Oberfläche, Filter, Import/Export, API, Tests, Anleitung |
**Nacharbeiten gesamt: 51 min** (2026-09-01, 20:25 – 21:16)
---
## Gesamtübersicht ## Gesamtübersicht
| Etappe | Version | Dauer | | Etappe | Version | Dauer |
@@ -101,8 +113,9 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| 4 — Universen | 0.4.0 | 14 min | | 4 — Universen | 0.4.0 | 14 min |
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min | | 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min | | 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
**Gesamtentwicklungszeit: 2 h 16 min** (136 min, 2026-09-01, 18:09 – 20:25) **Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16)
### Womit die Zeit verging ### Womit die Zeit verging
@@ -111,7 +124,7 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) | | ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) |
| ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) | | ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) |
| ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) | | ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) |
| ~20 min | Tests — 49 Stück, die unterwegs sieben echte Fehler gefunden haben | | ~25 min | Tests — 52 Stück, die unterwegs acht echte Fehler gefunden haben |
| ~15 min | Dokumentation, Screenshots, Changelog, Releases | | ~15 min | Dokumentation, Screenshots, Changelog, Releases |
| ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette | | ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette |
@@ -124,3 +137,14 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel. 5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel.
6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build. 6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build.
7. Der SVG-Titel eines Diagramms brach die Hydration. 7. Der SVG-Titel eines Diagramms brach die Hydration.
8. Eine Testerwartung zur Reihenfolge war falsch — nicht der Code.
### Fehler, die erst der echte Containerstart zeigte
1. Dem Laufzeitimage fehlten transitive Abhängigkeiten der Prisma-CLI, die
Migration lief nicht an. Die CLI wird jetzt in einer eigenen Build-Stufe
vollständig installiert.
2. npm blockiert seit Version 11 Install-Skripte; ohne Freigabe fehlte die
plattformpassende Prisma-Engine.
3. `next/font/google` lud die Schrift beim Bauen herunter und ließ den Build
im Container hängen. Die Schrift liegt jetzt im Repository.
+9 -5
View File
@@ -16,10 +16,10 @@ 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 standardmäßig. Prisma braucht seine # npm blockt Install-Skripte seit Version 11. Welche laufen dürfen, steht im
# eigenen, um die passende Engine für die Plattform bereitzulegen — deshalb hier # Feld "allowScripts" der package.json — für Prisma, damit die zur Plattform
# gezielt erlaubt statt pauschal. # passende Engine bereitgelegt wird.
RUN npm ci --no-audit --no-fund --allow-scripts "prisma,@prisma/engines" RUN npm ci --no-audit --no-fund
# --- Build ------------------------------------------------------------------- # --- Build -------------------------------------------------------------------
FROM base AS builder FROM base AS builder
@@ -41,7 +41,11 @@ RUN npx prisma generate && npx next build
# schief: @prisma/config braucht weitere Module, die dabei fehlten. # schief: @prisma/config braucht weitere Module, die dabei fehlten.
FROM base AS prisma-cli FROM base AS prisma-cli
WORKDIR /cli WORKDIR /cli
RUN npm init -y > /dev/null && npm install --omit=dev --no-audit --no-fund --allow-scripts "prisma,@prisma/engines" prisma@7.10.0 # 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
+3
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
+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
}
}
+42 -13
View File
@@ -17,19 +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. [Öffentliches Profil](#öffentliches-profil) 15. [Verwaltung](#verwaltung)
16. [Auf dem Handy](#auf-dem-handy) 16. [Öffentliches Profil](#öffentliches-profil)
17. [Helles Farbschema](#helles-farbschema) 17. [Auf dem Handy](#auf-dem-handy)
18. [Helles Farbschema](#helles-farbschema)
--- ---
@@ -115,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)
+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
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.6.1", "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",
@@ -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,
}, },
}); });
+19 -4
View File
@@ -1,8 +1,23 @@
# Flimmer 0.6.1 # Flimmer 0.7.0
Nacharbeiten aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich „Hab ich" — Titel, die in der eigenen Sammlung liegen. Dazu die Nacharbeiten
gebaut und gestartet — dabei kam ein Fehler ans Licht, den nur der echte aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich gebaut und
Containerstart zeigt. 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 ### Behoben
+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,
}; };
+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();
+22 -2
View File
@@ -19,8 +19,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
<div className="relative"> <div className="relative">
<Poster path={item.posterPath} alt={item.title} size="w342" /> <Poster path={item.posterPath} alt={item.title} size="w342" />
{/* Status und Sammlung teilen sich die obere Zeile — unten sitzt bei
den Postern oft der Titel, dort würden sie ihn verdecken. */}
<span className="absolute left-2 top-2 flex flex-wrap gap-1">
<span <span
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md" className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
style={{ style={{
color: STATUS_COLOR[status], color: STATUS_COLOR[status],
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`, backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
@@ -30,6 +33,19 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
{STATUS_LABEL[status]} {STATUS_LABEL[status]}
</span> </span>
{item.owned && (
<span
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
title={
item.ownedSource ? `In deiner Sammlung: ${item.ownedSource}` : "In deiner Sammlung"
}
>
Hab ich
</span>
)}
</span>
{item.rating != null && ( {item.rating != null && (
<span <span
className="absolute right-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]" className="absolute right-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
@@ -51,7 +67,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
<p className="text-xs text-[var(--fl-faint)]"> <p className="text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"} {item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""} {item.year ? ` · ${item.year}` : ""}
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""} {item.owned && item.ownedSource
? ` · ${item.ownedSource}`
: item.whereToWatch
? ` · ${item.whereToWatch}`
: ""}
</p> </p>
{showProgress && ( {showProgress && (
+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,
}; };
} }
+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] } } });
});