diff --git a/CHANGELOG.md b/CHANGELOG.md index 91a37d8..d85e7f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,26 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [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 -gebaut und gestartet — dabei kam ein Fehler ans Licht, den nur der echte -Containerstart zeigt. +„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 @@ -306,8 +321,8 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung. dass weitere Sprachen als zusätzliche Datei dazukommen können. - **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation. -[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.6.1...HEAD -[0.6.1]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.1 +[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.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 diff --git a/DEVLOG.md b/DEVLOG.md index 84a06e3..936f6eb 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -91,6 +91,18 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt. --- +## Nach der Abnahme (v0.7.0) + +| # | Start | Ende | Dauer | Arbeitsschritt | +|---|---|---|---|---| +| 7.1 | 20:25 | 20:50 | 25 min | Docker-Image zum ersten Mal wirklich gebaut und gestartet; zwei Fehler behoben (fehlende Prisma-Abhängigkeiten im Laufzeitimage, Schriftabruf beim Bauen), Gitea-Workflows, Erscheinungsmeldung nachgezogen | +| 7.2 | 20:50 | 21:05 | 15 min | Compose-Stack von null verifiziert: Migration, erstes Konto wird Administrator, Verwaltung erreichbar | +| 7.3 | 21:05 | 21:16 | 11 min | Rückfrage aus dem Betrieb umgesetzt: „hab ich" für die eigene Sammlung (Jellyfin, Plex, NAS, Blu-ray) — Schema, Logik, Oberfläche, Filter, Import/Export, API, Tests, Anleitung | + +**Nacharbeiten gesamt: 51 min** (2026-09-01, 20:25 – 21:16) + +--- + ## Gesamtübersicht | Etappe | Version | Dauer | @@ -101,8 +113,9 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt. | 4 — Universen | 0.4.0 | 14 min | | 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min | | 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min | +| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min | -**Gesamtentwicklungszeit: 2 h 16 min** (136 min, 2026-09-01, 18:09 – 20:25) +**Gesamtentwicklungszeit: 3 h 07 min** (187 min, 2026-09-01, 18:09 – 21:16) ### Womit die Zeit verging @@ -111,7 +124,7 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt. | ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) | | ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) | | ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) | -| ~20 min | Tests — 49 Stück, die unterwegs sieben echte Fehler gefunden haben | +| ~25 min | Tests — 52 Stück, die unterwegs acht echte Fehler gefunden haben | | ~15 min | Dokumentation, Screenshots, Changelog, Releases | | ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette | @@ -124,3 +137,14 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt. 5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel. 6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build. 7. Der SVG-Titel eines Diagramms brach die Hydration. +8. Eine Testerwartung zur Reihenfolge war falsch — nicht der Code. + +### Fehler, die erst der echte Containerstart zeigte + +1. Dem Laufzeitimage fehlten transitive Abhängigkeiten der Prisma-CLI, die + Migration lief nicht an. Die CLI wird jetzt in einer eigenen Build-Stufe + vollständig installiert. +2. npm blockiert seit Version 11 Install-Skripte; ohne Freigabe fehlte die + plattformpassende Prisma-Engine. +3. `next/font/google` lud die Schrift beim Bauen herunter und ließ den Build + im Container hängen. Die Schrift liegt jetzt im Repository. diff --git a/Dockerfile b/Dockerfile index 4e46135..0b971ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,10 +16,10 @@ ENV NEXT_TELEMETRY_DISABLED=1 FROM base AS deps WORKDIR /app COPY package.json package-lock.json ./ -# npm blockt Install-Skripte seit Version 11 standardmäßig. Prisma braucht seine -# eigenen, um die passende Engine für die Plattform bereitzulegen — deshalb hier -# gezielt erlaubt statt pauschal. -RUN npm ci --no-audit --no-fund --allow-scripts "prisma,@prisma/engines" +# npm blockt Install-Skripte seit Version 11. Welche laufen dürfen, steht im +# Feld "allowScripts" der package.json — für Prisma, damit die zur Plattform +# passende Engine bereitgelegt wird. +RUN npm ci --no-audit --no-fund # --- Build ------------------------------------------------------------------- FROM base AS builder @@ -41,7 +41,11 @@ RUN npx prisma generate && npx next build # schief: @prisma/config braucht weitere Module, die dabei fehlten. FROM base AS prisma-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 ---------------------------------------------------------------- FROM base AS runner diff --git a/README.md b/README.md index f9a16d4..cadc874 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ bleiben auf deinem Server. Story-Chronologie, Einsteiger-Route) und Gesamtfortschritt. - **Metadaten von TMDB** — Suche, Poster, Staffeln und Episoden, offizielle Collections, Streaming-Verfügbarkeit in deiner Region. +- **Eigene Sammlung** — markiere, was du schon hast, und wo: Jellyfin, Plex, + NAS, Blu-ray. Gerade die Titel, die nirgends mehr streamen, findest du so + wieder. - **Benachrichtigungen** — bei neuen Episoden und Releases, per E-Mail, ntfy, Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar. - **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen diff --git a/docker/prisma-cli-package.json b/docker/prisma-cli-package.json new file mode 100644 index 0000000..050dbbf --- /dev/null +++ b/docker/prisma-cli-package.json @@ -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 + } +} diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index dcd9d9d..89451fa 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -17,19 +17,20 @@ die echten. 2. [Die Übersicht](#die-übersicht) 3. [Titel finden und aufnehmen](#titel-finden-und-aufnehmen) 4. [Eine Serie verfolgen](#eine-serie-verfolgen) -5. [Die Bibliothek](#die-bibliothek) -6. [Universen](#universen) -7. [Kalender](#kalender) -8. [Statistik](#statistik) -9. [Einstellungen](#einstellungen) -10. [Benachrichtigungen](#benachrichtigungen) -11. [Import und Export](#import-und-export) -12. [API-Schlüssel](#api-schlüssel) -13. [Papierkorb](#papierkorb) -14. [Verwaltung](#verwaltung) -15. [Öffentliches Profil](#öffentliches-profil) -16. [Auf dem Handy](#auf-dem-handy) -17. [Helles Farbschema](#helles-farbschema) +5. [Hab ich — die eigene Sammlung](#hab-ich--die-eigene-sammlung) +6. [Die Bibliothek](#die-bibliothek) +7. [Universen](#universen) +8. [Kalender](#kalender) +9. [Statistik](#statistik) +10. [Einstellungen](#einstellungen) +11. [Benachrichtigungen](#benachrichtigungen) +12. [Import und Export](#import-und-export) +13. [API-Schlüssel](#api-schlüssel) +14. [Papierkorb](#papierkorb) +15. [Verwaltung](#verwaltung) +16. [Öffentliches Profil](#öffentliches-profil) +17. [Auf dem Handy](#auf-dem-handy) +18. [Helles Farbschema](#helles-farbschema) --- @@ -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 ![Bibliothek](screenshots/05-bibliothek.webp) diff --git a/docs/API.md b/docs/API.md index 86b41df..4297673 100644 --- a/docs/API.md +++ b/docs/API.md @@ -57,8 +57,9 @@ GET /api/v1/bibliothek?status=WATCHING&typ=SERIES&genre=Drama&q=nord&limit=50&o POST /api/v1/bibliothek ``` -Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `limit`, `offset`. -Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`. +Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `sammlung`, `limit`, +`offset`. Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`. +`sammlung=1` liefert nur Titel, die in der eigenen Sammlung liegen. Aufnehmen — entweder mit interner Kennung oder direkt mit der TMDB-Kennung: @@ -75,7 +76,9 @@ DELETE /api/v1/bibliothek/{entryId} ``` `GET` liefert den Eintrag samt `progress` und `nextEpisode`. -`PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite`. +`PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite` +sowie `owned` (liegt der Titel in der eigenen Sammlung?) und `ownedSource` +(wo genau — Jellyfin, Plex, Blu-ray …). `DELETE` legt den Eintrag in den Papierkorb — dort bleibt er 30 Tage. ### Sichtungen @@ -161,6 +164,14 @@ GET /api/v1/export?format=csv JSON enthält zusätzlich alle Sichtungen und Universen. +## Beispiel: was ich habe, aber noch nicht gesehen habe + +```bash +curl -s -H "Authorization: Bearer $KEY" \ + "$HOST/api/v1/bibliothek?sammlung=1&status=WATCHLIST" \ +| jq -r '.items[] | "\(.title) — liegt bei: \(.ownedSource // "ohne Angabe")"' +``` + ## Beispiel: Serien mit offener Episode auflisten ```bash diff --git a/docs/screenshots/20-sammlung.webp b/docs/screenshots/20-sammlung.webp new file mode 100644 index 0000000..56b5aff Binary files /dev/null and b/docs/screenshots/20-sammlung.webp differ diff --git a/package.json b/package.json index 5d4d2a9..e5c4a50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flimmer", - "version": "0.6.1", + "version": "0.7.0", "private": true, "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "license": "MIT", diff --git a/prisma/migrations/20260901190508_sammlung/migration.sql b/prisma/migrations/20260901190508_sammlung/migration.sql new file mode 100644 index 0000000..b45dfb7 --- /dev/null +++ b/prisma/migrations/20260901190508_sammlung/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e6098e7..30b3d16 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -289,6 +289,13 @@ model LibraryEntry { /// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar whereToWatch String? + /// Liegt der Titel in der eigenen Sammlung? Unabhängig davon, ob ihn gerade + /// ein Streamingdienst führt — gekaufte Filme laufen irgendwann nirgends mehr. + owned Boolean @default(false) + + /// Wo genau: „Jellyfin", „Blu-ray", „NAS", „Plex" … + ownedSource String? + favorite Boolean @default(false) /// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch … @@ -309,6 +316,7 @@ model LibraryEntry { @@unique([userId, titleId]) @@index([userId, status]) @@index([userId, updatedAt]) + @@index([userId, owned]) @@index([deletedAt]) } diff --git a/prisma/seed.ts b/prisma/seed.ts index c01caed..44a4b6c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -244,6 +244,9 @@ type EntryPlan = { startedDaysAgo?: number; /** Abstand zwischen zwei Episoden in Tagen. */ spacingDays?: number; + /** Liegt der Titel in der eigenen Sammlung? */ + owned?: boolean; + ownedSource?: string; }; const ENTRY_PLANS: EntryPlan[] = [ @@ -315,8 +318,8 @@ const ENTRY_PLANS: EntryPlan[] = [ }, { key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] }, - { key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340 }, - { key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320 }, + { key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340, owned: true, ownedSource: "Jellyfin" }, + { key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320, owned: true, ownedSource: "Jellyfin" }, { key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 }, { key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] }, { key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] }, @@ -330,7 +333,7 @@ const ENTRY_PLANS: EntryPlan[] = [ startedDaysAgo: 180, }, { key: "stille-bilder-2", status: "WATCHLIST" }, - { key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60 }, + { key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60, owned: true, ownedSource: "Blu-ray" }, { key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 }, { key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 }, ]; @@ -355,6 +358,8 @@ async function seedLibrary(userId: string, titleIds: Map) { rating: plan.rating ?? null, notes: plan.notes ?? null, favorite: plan.favorite ?? false, + owned: plan.owned ?? false, + ownedSource: plan.ownedSource ?? null, currentRun, startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null, finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null, @@ -364,6 +369,8 @@ async function seedLibrary(userId: string, titleIds: Map) { rating: plan.rating ?? null, notes: plan.notes ?? null, favorite: plan.favorite ?? false, + owned: plan.owned ?? false, + ownedSource: plan.ownedSource ?? null, currentRun, }, }); diff --git a/releases/v0.6.1.md b/releases/v0.7.0.md similarity index 66% rename from releases/v0.6.1.md rename to releases/v0.7.0.md index 43fa3ff..43e5264 100644 --- a/releases/v0.6.1.md +++ b/releases/v0.7.0.md @@ -1,8 +1,23 @@ -# Flimmer 0.6.1 +# Flimmer 0.7.0 -Nacharbeiten aus der Abnahme: das Docker-Image wurde zum ersten Mal wirklich -gebaut und gestartet — dabei kam ein Fehler ans Licht, den nur der echte -Containerstart zeigt. +„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 diff --git a/src/app/actions/library.ts b/src/app/actions/library.ts index 2781ca2..fc824cf 100644 --- a/src/app/actions/library.ts +++ b/src/app/actions/library.ts @@ -114,6 +114,8 @@ const detailsSchema = z.object({ notes: z.string().max(5000).nullable().optional(), whereToWatch: z.string().max(120).nullable().optional(), favorite: z.boolean().optional(), + owned: z.boolean().optional(), + ownedSource: z.string().max(80).nullable().optional(), }); export async function updateEntryAction(entryId: string, data: unknown) { diff --git a/src/app/api/v1/bibliothek/[entryId]/route.ts b/src/app/api/v1/bibliothek/[entryId]/route.ts index b00cdd5..7c9c9eb 100644 --- a/src/app/api/v1/bibliothek/[entryId]/route.ts +++ b/src/app/api/v1/bibliothek/[entryId]/route.ts @@ -36,6 +36,8 @@ export const GET = withApi(async (_request, user, context) => { rating: entry.rating, notes: entry.notes, whereToWatch: entry.whereToWatch, + owned: entry.owned, + ownedSource: entry.ownedSource, favorite: entry.favorite, currentRun: entry.currentRun, tags: entry.tags.map((link) => link.tag.name), @@ -58,6 +60,8 @@ const patchSchema = z.object({ notes: z.string().max(5000).nullable().optional(), whereToWatch: z.string().max(120).nullable().optional(), favorite: z.boolean().optional(), + owned: z.boolean().optional(), + ownedSource: z.string().max(80).nullable().optional(), }); /** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */ diff --git a/src/app/api/v1/bibliothek/route.ts b/src/app/api/v1/bibliothek/route.ts index 983d462..4945090 100644 --- a/src/app/api/v1/bibliothek/route.ts +++ b/src/app/api/v1/bibliothek/route.ts @@ -22,6 +22,7 @@ export const GET = withApi(async (request, user) => { genre: url.searchParams.get("genre") ?? undefined, tag: url.searchParams.get("tag") ?? undefined, provider: url.searchParams.get("dienst") ?? undefined, + owned: url.searchParams.get("sammlung") === "1" ? true : undefined, }); return { diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts index 1a2ab4e..9d61d71 100644 --- a/src/app/api/v1/openapi.json/route.ts +++ b/src/app/api/v1/openapi.json/route.ts @@ -73,6 +73,12 @@ export function GET() { { name: "genre", in: "query", schema: { type: "string" } }, { name: "tag", in: "query", schema: { type: "string" } }, { name: "dienst", in: "query", schema: { type: "string" } }, + { + name: "sammlung", + in: "query", + schema: { type: "string", enum: ["1"] }, + description: "Nur Titel, die in der eigenen Sammlung liegen.", + }, { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } }, { name: "offset", in: "query", schema: { type: "integer", default: 0 } }, ], @@ -123,6 +129,15 @@ export function GET() { notes: { type: "string", nullable: true }, whereToWatch: { type: "string", nullable: true }, favorite: { type: "boolean" }, + owned: { + type: "boolean", + description: "Liegt der Titel in der eigenen Sammlung?", + }, + ownedSource: { + type: "string", + nullable: true, + description: "Wo genau — Jellyfin, Plex, Blu-ray …", + }, }, }, }, diff --git a/src/app/bibliothek/page.tsx b/src/app/bibliothek/page.tsx index 2ef9acc..1b70e0f 100644 --- a/src/app/bibliothek/page.tsx +++ b/src/app/bibliothek/page.tsx @@ -29,6 +29,7 @@ export default async function LibraryPage({ genre: params.genre || undefined, tag: params.tag || undefined, provider: params.dienst || undefined, + owned: params.sammlung === "1" ? true : undefined, sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined, }; diff --git a/src/app/titel/[id]/page.tsx b/src/app/titel/[id]/page.tsx index c9c6bb5..f98a123 100644 --- a/src/app/titel/[id]/page.tsx +++ b/src/app/titel/[id]/page.tsx @@ -120,6 +120,19 @@ export default async function TitlePage({ params }: { params: Params }) { {year ? ` · ${year}` : ""} {title.status ? ` · ${title.status}` : ""}

+ {entry?.owned && ( + + Hab ich{entry.ownedSource ? ` · ${entry.ownedSource}` : ""} + + )} {entry && ( ) : (
+
)} @@ -245,6 +259,8 @@ export default async function TitlePage({ params }: { params: Params }) { whereToWatch={entry.whereToWatch} tags={entry.tags.map((link) => link.tag.name)} suggestion={suggestion} + owned={entry.owned} + ownedSource={entry.ownedSource} /> @@ -253,6 +269,7 @@ export default async function TitlePage({ params }: { params: Params }) {

Wo läuft das?

+ )} @@ -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 ( +

+ + + + + In deiner Sammlung + {source ? · {source} : null} + +

+ ); +} + function Fact({ label, children }: { label: string; children: React.ReactNode }) { return (
diff --git a/src/components/library/EntryNotes.tsx b/src/components/library/EntryNotes.tsx index f0f59fd..c24af40 100644 --- a/src/components/library/EntryNotes.tsx +++ b/src/components/library/EntryNotes.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useRef, useState, useTransition } from "react"; +import { useRef, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; +import { clsx } from "clsx"; import { setTagsAction, updateEntryAction } from "@/app/actions/library"; import { useToasts } from "@/components/Toasts"; @@ -17,6 +18,8 @@ export function EntryNotes({ whereToWatch, tags, suggestion, + owned, + ownedSource, }: { entryId: string; notes: string | null; @@ -24,9 +27,12 @@ export function EntryNotes({ tags: string[]; /** Vorschlag aus der Streaming-Verfügbarkeit. */ suggestion: string | null; + owned: boolean; + ownedSource: string | null; }) { return (
+ @@ -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 ( +
+ + + {isOwned && ( +
+ + 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" + /> + + +
+ )} +
+ ); +} + function useAutosave(entryId: string) { const { report } = useToasts(); const router = useRouter(); diff --git a/src/components/library/LibraryCard.tsx b/src/components/library/LibraryCard.tsx index 3567fd0..abc50c5 100644 --- a/src/components/library/LibraryCard.tsx +++ b/src/components/library/LibraryCard.tsx @@ -19,15 +19,31 @@ export function LibraryCard({ item }: { item: LibraryItem }) {
- - {STATUS_LABEL[status]} + {/* Status und Sammlung teilen sich die obere Zeile — unten sitzt bei + den Postern oft der Titel, dort würden sie ihn verdecken. */} + + + {STATUS_LABEL[status]} + + + {item.owned && ( + + Hab ich + + )} {item.rating != null && ( @@ -51,7 +67,11 @@ export function LibraryCard({ item }: { item: LibraryItem }) {

{item.type === "SERIES" ? "Serie" : "Film"} {item.year ? ` · ${item.year}` : ""} - {item.whereToWatch ? ` · ${item.whereToWatch}` : ""} + {item.owned && item.ownedSource + ? ` · ${item.ownedSource}` + : item.whereToWatch + ? ` · ${item.whereToWatch}` + : ""}

{showProgress && ( diff --git a/src/components/library/LibraryFilters.tsx b/src/components/library/LibraryFilters.tsx index 0b4ec18..6d60ff7 100644 --- a/src/components/library/LibraryFilters.tsx +++ b/src/components/library/LibraryFilters.tsx @@ -56,7 +56,9 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) { const status = params.get("status"); const type = params.get("typ"); const sort = params.get("sortierung") ?? "aktualisiert"; - const activeCount = ["genre", "tag", "dienst", "typ"].filter((key) => params.get(key)).length; + const activeCount = ["genre", "tag", "dienst", "typ", "sammlung"].filter((key) => + params.get(key), + ).length; return (
@@ -78,6 +80,15 @@ export function LibraryFilters({ facets }: { facets: LibraryFacets }) { {STATUS_LABEL[key as StatusKey]} ))} + + {facets.owned > 0 && ( + apply({ sammlung: params.get("sammlung") === "1" ? null : "1" })} + > + Hab ich + + )}
diff --git a/src/lib/library.ts b/src/lib/library.ts index cb2d31e..8a6b926 100644 --- a/src/lib/library.ts +++ b/src/lib/library.ts @@ -110,7 +110,14 @@ export async function setStatus(userId: string, entryId: string, status: EntrySt export async function updateEntryDetails( userId: string, entryId: string, - data: { rating?: number | null; notes?: string | null; whereToWatch?: string | null; favorite?: boolean }, + data: { + rating?: number | null; + notes?: string | null; + whereToWatch?: string | null; + favorite?: boolean; + owned?: boolean; + ownedSource?: string | null; + }, ) { await requireEntry(userId, entryId); @@ -127,6 +134,10 @@ export async function updateEntryDetails( ? { whereToWatch: data.whereToWatch?.trim() || null } : {}), ...(data.favorite !== undefined ? { favorite: data.favorite } : {}), + ...(data.owned !== undefined ? { owned: data.owned } : {}), + ...(data.ownedSource !== undefined + ? { ownedSource: data.ownedSource?.trim() || null } + : {}), }, }); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 1ab9dda..79417ab 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -19,6 +19,8 @@ export type LibraryFilters = { genre?: string; tag?: string; provider?: string; + /** Nur Titel, die in der eigenen Sammlung liegen. */ + owned?: boolean; sort?: LibrarySort; }; @@ -42,6 +44,8 @@ export type LibraryItem = { favorite: boolean; currentRun: number; whereToWatch: string | null; + owned: boolean; + ownedSource: string | null; tags: string[]; watched: number; total: number; @@ -56,6 +60,7 @@ export async function getLibrary( userId, deletedAt: null, ...(filters.status ? { status: filters.status } : {}), + ...(filters.owned ? { owned: true } : {}), ...(filters.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}), title: { ...(filters.type ? { type: filters.type } : {}), @@ -116,6 +121,8 @@ export async function getLibrary( favorite: entry.favorite, currentRun: entry.currentRun, whereToWatch: entry.whereToWatch, + owned: entry.owned, + ownedSource: entry.ownedSource, tags: entry.tags.map((link) => link.tag.name), watched, total, @@ -162,11 +169,13 @@ export type LibraryFacets = { tags: string[]; providers: string[]; counts: Record; + /** Wie viele Titel in der eigenen Sammlung liegen. */ + owned: number; }; /** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */ export async function getLibraryFacets(userId: string): Promise { - const [genreRows, tagRows, providerRows, statusRows, total] = await Promise.all([ + const [genreRows, tagRows, providerRows, statusRows, total, owned] = await Promise.all([ prisma.$queryRaw<{ genre: string }[]>` SELECT DISTINCT unnest(t."genres") AS genre FROM "LibraryEntry" e @@ -193,6 +202,7 @@ export async function getLibraryFacets(userId: string): Promise { _count: { _all: true }, }), prisma.libraryEntry.count({ where: { userId, deletedAt: null } }), + prisma.libraryEntry.count({ where: { userId, deletedAt: null, owned: true } }), ]); const counts = { @@ -211,6 +221,7 @@ export async function getLibraryFacets(userId: string): Promise { tags: tagRows.map((row) => row.name), providers: providerRows.map((row) => row.name), counts, + owned, }; } diff --git a/src/lib/transfer.ts b/src/lib/transfer.ts index 0cb5091..8c81403 100644 --- a/src/lib/transfer.ts +++ b/src/lib/transfer.ts @@ -135,6 +135,11 @@ const CSV_HEADERS: Record = { notiz: "notes", notes: "notes", kommentar: "notes", + habich: "owned", + owned: "owned", + besitz: "owned", + sammlung: "ownedSource", + quelle: "ownedSource", }; /** @@ -185,6 +190,8 @@ export async function importCsv(userId: string, text: string): Promise= 1 && record.rating <= 10 ? record.rating : null, notes: record.notes, + owned: record.owned ?? false, + ownedSource: record.ownedSource ?? null, }, }); } @@ -394,6 +405,8 @@ export async function exportCsv(userId: string): Promise { "Status", "Bewertung", "Durchlaeufe", + "HabIch", + "Sammlung", "WoGeschaut", "Tags", "Notiz", @@ -410,6 +423,8 @@ export async function exportCsv(userId: string): Promise { entry.status, entry.rating ?? "", entry.currentRun + 1, + entry.owned ? "ja" : "nein", + entry.ownedSource ?? "", entry.whereToWatch ?? "", entry.tags.map((link) => link.tag.name).join(" "), entry.notes?.replace(/\r?\n/g, " ") ?? "", @@ -479,6 +494,8 @@ export async function exportJson(userId: string): Promise { rating: entry.rating, notes: entry.notes, whereToWatch: entry.whereToWatch, + owned: entry.owned, + ownedSource: entry.ownedSource, favorite: entry.favorite, currentRun: entry.currentRun, tags: entry.tags.map((link) => link.tag.name), diff --git a/tests/library.test.ts b/tests/library.test.ts index d18d675..c6f4510 100644 --- a/tests/library.test.ts +++ b/tests/library.test.ts @@ -336,3 +336,55 @@ test("fremde Einträge sind nicht erreichbar", async () => { await prisma.user.delete({ where: { id: other.id } }); }); + +test("Sammlung ist unabhängig von der Streaming-Verfügbarkeit", async () => { + const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST"); + + const fresh = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } }); + assert.equal(fresh.owned, false, "standardmäßig gehört einem nichts"); + assert.equal(fresh.ownedSource, null); + + const owned = await updateEntryDetails(userId, entry.id, { + owned: true, + ownedSource: " Jellyfin ", + }); + assert.equal(owned.owned, true); + assert.equal(owned.ownedSource, "Jellyfin", "Rand wird beschnitten"); + + // Der Fundort bleibt davon unberührt: das eine sagt, wo es läuft, + // das andere, wo es bei mir liegt. + assert.equal( + owned.whereToWatch, + "Disney Plus", + "der Vorschlag aus der Verfügbarkeit bleibt davon unberührt", + ); + + const released = await updateEntryDetails(userId, entry.id, { + owned: false, + ownedSource: "", + }); + assert.equal(released.owned, false); + assert.equal(released.ownedSource, null, "leerer Text wird zu null"); +}); + +test("Sammlung: Filter und Zähler in der Bibliothek", async () => { + const movie = await addToLibrary(userId, movieTitleId, "WATCHLIST"); + const series = await addToLibrary(userId, seriesTitleId, "WATCHING"); + await updateEntryDetails(userId, movie.id, { owned: true, ownedSource: "Blu-ray" }); + + const { getLibrary, getLibraryFacets } = await import("../src/lib/queries"); + + const alle = await getLibrary(userId, {}); + assert.equal(alle.length, 2); + + const nurSammlung = await getLibrary(userId, { owned: true }); + assert.equal(nurSammlung.length, 1); + assert.equal(nurSammlung[0]?.entryId, movie.id); + assert.equal(nurSammlung[0]?.ownedSource, "Blu-ray"); + + const facets = await getLibraryFacets(userId); + assert.equal(facets.owned, 1, "der Zähler der Filterleiste stimmt"); + assert.equal(facets.counts.ALLE, 2); + + await prisma.libraryEntry.deleteMany({ where: { id: { in: [movie.id, series.id] } } }); +});