import "server-only"; import { prisma } from "./db"; import { SPECIALS_SEASON } from "./catalog"; import type { OrderKind, SharePermission, TitleType } from "@/generated/prisma/client"; /** * Universen: eigene Sammlungen aus Filmen **und** Serien. * * Zwei Dinge unterscheiden ein Universum von einer Liste: * * 1. Es hat **mehrere Reihenfolgen**. Die Erscheinungsreihenfolge wird aus den * Daten berechnet und nie gespeichert — sie stimmt damit immer. Alles * andere (Story-Chronologie, Einsteiger-Route) ist frei sortierbar. * 2. Der **Fortschritt** ist der des jeweiligen Betrachters. Ein geteiltes * Universum zeigt jeder Person ihren eigenen Stand. */ export class UniverseError extends Error {} // --------------------------------------------------------------------------- // Zugriff // --------------------------------------------------------------------------- export type UniverseAccess = { canRead: boolean; canWrite: boolean; isOwner: boolean }; export async function getAccess(userId: string, universeId: string): Promise { const universe = await prisma.universe.findFirst({ where: { id: universeId, deletedAt: null }, select: { ownerId: true, members: { where: { userId }, select: { permission: true } }, }, }); if (!universe) return { canRead: false, canWrite: false, isOwner: false }; const isOwner = universe.ownerId === userId; const membership = universe.members[0]; return { isOwner, canRead: isOwner || Boolean(membership), canWrite: isOwner || membership?.permission === "WRITE", }; } async function requireWrite(userId: string, universeId: string) { const access = await getAccess(userId, universeId); if (!access.canRead) throw new UniverseError("Dieses Universum gibt es nicht."); if (!access.canWrite) throw new UniverseError("Du darfst dieses Universum nur ansehen."); return access; } // --------------------------------------------------------------------------- // Anlegen und pflegen // --------------------------------------------------------------------------- function slugify(value: string): string { return value .toLocaleLowerCase("de-DE") .replace(/ä/g, "ae") .replace(/ö/g, "oe") .replace(/ü/g, "ue") .replace(/ß/g, "ss") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60) || "universum"; } export async function createUniverse(userId: string, name: string, description?: string) { const base = slugify(name); let slug = base; // Der Slug ist je Besitzer eindeutig — bei Dubletten durchzählen. for (let suffix = 2; suffix < 100; suffix += 1) { const taken = await prisma.universe.findUnique({ where: { ownerId_slug: { ownerId: userId, slug } }, select: { id: true }, }); if (!taken) break; slug = `${base}-${suffix}`; } const universe = await prisma.universe.create({ data: { ownerId: userId, name: name.trim(), slug, description: description?.trim() || null, orders: { create: [ // Die Erscheinungsreihenfolge braucht keine Einträge — sie wird // aus den Erscheinungsdaten berechnet. { name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }, ], }, }, }); return universe; } export async function renameUniverse( userId: string, universeId: string, data: { name?: string; description?: string | null }, ) { await requireWrite(userId, universeId); return prisma.universe.update({ where: { id: universeId }, data: { ...(data.name ? { name: data.name.trim() } : {}), ...(data.description !== undefined ? { description: data.description?.trim() || null } : {}), }, }); } export async function trashUniverse(userId: string, universeId: string) { const access = await getAccess(userId, universeId); if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf es löschen."); return prisma.universe.update({ where: { id: universeId }, data: { deletedAt: new Date() }, }); } /** Nimmt einen Titel ins Universum auf und hängt ihn an alle Reihenfolgen an. */ export async function addTitle(userId: string, universeId: string, titleId: string) { await requireWrite(userId, universeId); const existing = await prisma.universeItem.findUnique({ where: { universeId_titleId: { universeId, titleId } }, select: { id: true }, }); if (existing) return existing; const item = await prisma.universeItem.create({ data: { universeId, titleId }, select: { id: true }, }); await appendToCustomOrders(universeId, item.id); return item; } /** Neue Einträge landen ans Ende jeder frei sortierten Reihenfolge. */ async function appendToCustomOrders(universeId: string, itemId: string) { const orders = await prisma.universeOrder.findMany({ where: { universeId, kind: "CUSTOM" }, select: { id: true }, }); for (const order of orders) { const last = await prisma.universeOrderItem.findFirst({ where: { orderId: order.id }, orderBy: { position: "desc" }, select: { position: true }, }); await prisma.universeOrderItem.create({ data: { orderId: order.id, itemId, position: (last?.position ?? -1) + 1 }, }); } } export async function removeTitle(userId: string, universeId: string, itemId: string) { await requireWrite(userId, universeId); await prisma.universeItem.delete({ where: { id: itemId } }); } /** * Übernimmt alle Filme einer TMDB-Collection, die bereits im Katalog liegen. * Der eigentliche Abruf bei TMDB passiert vorher in der Katalogschicht. */ export async function addCollection( userId: string, universeId: string, tmdbCollectionId: number, ): Promise { await requireWrite(userId, universeId); const titles = await prisma.title.findMany({ where: { tmdbCollectionId }, orderBy: { releaseDate: "asc" }, select: { id: true }, }); let added = 0; for (const title of titles) { const existing = await prisma.universeItem.findUnique({ where: { universeId_titleId: { universeId, titleId: title.id } }, select: { id: true }, }); if (existing) continue; const item = await prisma.universeItem.create({ data: { universeId, titleId: title.id }, select: { id: true }, }); await appendToCustomOrders(universeId, item.id); added += 1; } return added; } // --------------------------------------------------------------------------- // Reihenfolgen // --------------------------------------------------------------------------- export async function createOrder(userId: string, universeId: string, name: string) { await requireWrite(userId, universeId); const order = await prisma.universeOrder.create({ data: { universeId, name: name.trim(), kind: "CUSTOM" }, select: { id: true }, }); // Startbelegung: Erscheinungsreihenfolge — von dort aus umsortiert es sich // leichter als aus einer zufälligen Anordnung. const items = await prisma.universeItem.findMany({ where: { universeId }, orderBy: [{ title: { releaseDate: "asc" } }, { title: { title: "asc" } }], select: { id: true }, }); await prisma.universeOrderItem.createMany({ data: items.map((item, index) => ({ orderId: order.id, itemId: item.id, position: index })), }); return order; } export async function renameOrder(userId: string, orderId: string, name: string) { const order = await prisma.universeOrder.findUniqueOrThrow({ where: { id: orderId }, select: { universeId: true, kind: true }, }); await requireWrite(userId, order.universeId); if (order.kind === "RELEASE") { throw new UniverseError("Die Erscheinungsreihenfolge lässt sich nicht umbenennen."); } return prisma.universeOrder.update({ where: { id: orderId }, data: { name: name.trim() } }); } export async function deleteOrder(userId: string, orderId: string) { const order = await prisma.universeOrder.findUniqueOrThrow({ where: { id: orderId }, select: { universeId: true, kind: true, isDefault: true }, }); await requireWrite(userId, order.universeId); if (order.kind === "RELEASE") { throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu."); } await prisma.$transaction(async (tx) => { await tx.universeOrder.delete({ where: { id: orderId } }); // War die gelöschte der Standard, stünde das Universum ohne einen da und // die Auswahl fiele auf „die erste" — Zufall statt Absicht. Die // Erscheinungsreihenfolge gehört immer dazu, sie übernimmt. if (order.isDefault) { await tx.universeOrder.updateMany({ where: { universeId: order.universeId, kind: "RELEASE" }, data: { isDefault: true }, }); } }); } /** * Legt fest, welche Reihenfolge beim Öffnen des Universums erscheint. * * Der Standard gehört dem Universum, nicht der einzelnen Person: wer * Schreibrecht hat, setzt ihn für alle, die das Universum sehen — so wie die * Reihenfolgen selbst auch geteilt sind. * * Zurücksetzen und Setzen laufen gemeinsam in einer Transaktion. Sonst stünden * dazwischen zwei Standards nebeneinander, und die Auswahl in `getUniverse()` * griffe sich nach Sortierzufall einen davon. */ export async function setDefaultOrder(userId: string, orderId: string) { const order = await prisma.universeOrder.findUniqueOrThrow({ where: { id: orderId }, select: { universeId: true, isDefault: true }, }); await requireWrite(userId, order.universeId); if (order.isDefault) return; await prisma.$transaction([ prisma.universeOrder.updateMany({ where: { universeId: order.universeId, isDefault: true }, data: { isDefault: false }, }), prisma.universeOrder.update({ where: { id: orderId }, data: { isDefault: true } }), ]); } /** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */ export async function setOrderPositions(userId: string, orderId: string, itemIds: string[]) { const order = await prisma.universeOrder.findUniqueOrThrow({ where: { id: orderId }, select: { universeId: true, kind: true }, }); await requireWrite(userId, order.universeId); if (order.kind === "RELEASE") { throw new UniverseError("Die Erscheinungsreihenfolge ergibt sich aus den Daten."); } // Erst alle auf negative Positionen schieben: die Eindeutigkeit von // (orderId, itemId) bleibt, aber wir vermeiden Kollisionen beim Umnummerieren. await prisma.$transaction( itemIds.map((itemId, index) => prisma.universeOrderItem.updateMany({ where: { orderId, itemId }, data: { position: index }, }), ), ); } // --------------------------------------------------------------------------- // Teilen // --------------------------------------------------------------------------- export async function shareUniverse( userId: string, universeId: string, targetEmail: string, permission: SharePermission, ) { const access = await getAccess(userId, universeId); if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf es teilen."); const target = await prisma.user.findUnique({ where: { email: targetEmail.trim().toLowerCase() }, select: { id: true, name: true }, }); if (!target) throw new UniverseError("Zu dieser E-Mail-Adresse gibt es auf dieser Instanz kein Konto."); if (target.id === userId) throw new UniverseError("Mit dir selbst musst du nicht teilen."); await prisma.universeMember.upsert({ where: { universeId_userId: { universeId, userId: target.id } }, create: { universeId, userId: target.id, permission }, update: { permission }, }); return target; } export async function unshareUniverse(userId: string, universeId: string, memberId: string) { const access = await getAccess(userId, universeId); if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf das ändern."); await prisma.universeMember.delete({ where: { id: memberId } }); } // --------------------------------------------------------------------------- // Lesen und Fortschritt // --------------------------------------------------------------------------- export type UniverseEntry = { itemId: string; titleId: string; title: string; type: TitleType; posterPath: string | null; year: number | null; note: string | null; /** Gesamtlaufzeit des Titels in Minuten. */ totalMinutes: number; /** Noch offene Laufzeit für diesen Betrachter. */ remainingMinutes: number; watchedMinutes: number; inLibrary: boolean; completed: boolean; status: string | null; watchedEpisodes: number; totalEpisodes: number; }; export type UniverseView = { id: string; name: string; description: string | null; isOwner: boolean; canWrite: boolean; ownerName: string; orders: { id: string; name: string; kind: OrderKind; isDefault: boolean }[]; activeOrderId: string; entries: UniverseEntry[]; progress: { completed: number; total: number; percent: number; watchedMinutes: number; remainingMinutes: number; totalMinutes: number; }; /** Der erste noch nicht abgeschlossene Eintrag in der gewählten Reihenfolge. */ nextUp: UniverseEntry | null; members: { id: string; name: string; email: string; permission: SharePermission }[]; }; export async function getUniverse( userId: string, universeId: string, orderId?: string, ): Promise { const access = await getAccess(userId, universeId); if (!access.canRead) return null; const universe = await prisma.universe.findUnique({ where: { id: universeId }, include: { owner: { select: { name: true } }, orders: { orderBy: [{ kind: "asc" }, { createdAt: "asc" }] }, members: { include: { user: { select: { name: true, email: true } } }, orderBy: { createdAt: "asc" }, }, items: { include: { title: { select: { id: true, title: true, type: true, posterPath: true, releaseDate: true, runtime: true, episodeCount: true, }, }, positions: true, }, }, }, }); if (!universe) return null; const activeOrder = universe.orders.find((order) => order.id === orderId) ?? universe.orders.find((order) => order.isDefault) ?? universe.orders[0]; if (!activeOrder) return null; const titleIds = universe.items.map((item) => item.titleId); const [entries, seriesRuntimes, watchedByTitle, watchedEpisodes] = await Promise.all([ prisma.libraryEntry.findMany({ where: { userId, titleId: { in: titleIds }, deletedAt: null }, select: { titleId: true, status: true, currentRun: true }, }), // Gesamtlaufzeit je Serie aus den Episoden (ohne Specials). prisma.episode.groupBy({ by: ["titleId"], where: { titleId: { in: titleIds }, seasonNumber: { gt: SPECIALS_SEASON } }, _sum: { runtime: true }, _count: { _all: true }, }), prisma.watch.groupBy({ by: ["titleId"], where: { userId, titleId: { in: titleIds } }, _sum: { minutes: true }, }), // Gesehene reguläre Episoden im jeweils aktuellen Durchlauf. prisma.watch.groupBy({ by: ["titleId", "run"], where: { userId, titleId: { in: titleIds }, episode: { seasonNumber: { gt: SPECIALS_SEASON } }, }, _count: { _all: true }, }), ]); const entryByTitle = new Map(entries.map((entry) => [entry.titleId, entry])); const runtimeByTitle = new Map( seriesRuntimes.map((row) => [row.titleId, { minutes: row._sum.runtime ?? 0, episodes: row._count._all }]), ); const minutesByTitle = new Map(watchedByTitle.map((row) => [row.titleId, row._sum.minutes ?? 0])); const seenByTitle = new Map(); for (const row of watchedEpisodes) { const entry = entryByTitle.get(row.titleId); if (!entry || entry.currentRun !== row.run) continue; seenByTitle.set(row.titleId, row._count._all); } const built: UniverseEntry[] = universe.items.map((item) => { const title = item.title; const isSeries = title.type === "SERIES"; const runtimeInfo = runtimeByTitle.get(title.id); const totalEpisodes = isSeries ? (runtimeInfo?.episodes ?? title.episodeCount) : 1; const totalMinutes = isSeries ? (runtimeInfo?.minutes ?? (title.runtime ?? 0) * title.episodeCount) : (title.runtime ?? 0); const entry = entryByTitle.get(title.id); const watchedEpisodeCount = isSeries ? Math.min(seenByTitle.get(title.id) ?? 0, totalEpisodes) : entry?.status === "COMPLETED" ? 1 : 0; const completed = entry?.status === "COMPLETED" || (totalEpisodes > 0 && watchedEpisodeCount >= totalEpisodes); // Verbleibende Zeit: bei Serien anteilig über die offenen Episoden, // bei Filmen alles oder nichts. const remainingMinutes = completed ? 0 : isSeries && totalEpisodes > 0 ? Math.round((totalMinutes * (totalEpisodes - watchedEpisodeCount)) / totalEpisodes) : totalMinutes; return { itemId: item.id, titleId: title.id, title: title.title, type: title.type, posterPath: title.posterPath, year: title.releaseDate?.getFullYear() ?? null, note: item.note, totalMinutes, remainingMinutes, watchedMinutes: minutesByTitle.get(title.id) ?? 0, inLibrary: Boolean(entry), completed, status: entry?.status ?? null, watchedEpisodes: watchedEpisodeCount, totalEpisodes, }; }); // Sortieren: Erscheinungsreihenfolge rechnerisch, sonst nach den Positionen. const positionByItem = new Map(); for (const item of universe.items) { const position = item.positions.find((row) => row.orderId === activeOrder.id); if (position) positionByItem.set(item.id, position.position); } const releaseYear = new Map( universe.items.map((item) => [item.id, item.title.releaseDate?.getTime() ?? Number.MAX_SAFE_INTEGER]), ); const sorted = [...built].sort((a, b) => { if (activeOrder.kind === "RELEASE") { const diff = (releaseYear.get(a.itemId) ?? 0) - (releaseYear.get(b.itemId) ?? 0); return diff !== 0 ? diff : a.title.localeCompare(b.title, "de"); } const positionA = positionByItem.get(a.itemId) ?? Number.MAX_SAFE_INTEGER; const positionB = positionByItem.get(b.itemId) ?? Number.MAX_SAFE_INTEGER; return positionA !== positionB ? positionA - positionB : a.title.localeCompare(b.title, "de"); }); const completed = sorted.filter((entry) => entry.completed).length; const watchedMinutes = sorted.reduce((sum, entry) => sum + entry.watchedMinutes, 0); const remainingMinutes = sorted.reduce((sum, entry) => sum + entry.remainingMinutes, 0); const totalMinutes = sorted.reduce((sum, entry) => sum + entry.totalMinutes, 0); return { id: universe.id, name: universe.name, description: universe.description, isOwner: access.isOwner, canWrite: access.canWrite, ownerName: universe.owner.name, orders: universe.orders.map((order) => ({ id: order.id, name: order.name, kind: order.kind, isDefault: order.isDefault, })), activeOrderId: activeOrder.id, entries: sorted, progress: { completed, total: sorted.length, percent: sorted.length > 0 ? Math.round((completed / sorted.length) * 100) : 0, watchedMinutes, remainingMinutes, totalMinutes, }, nextUp: sorted.find((entry) => !entry.completed) ?? null, members: universe.members.map((member) => ({ id: member.id, name: member.user.name, email: member.user.email, permission: member.permission, })), }; } export type UniverseSummary = { id: string; name: string; description: string | null; isOwner: boolean; ownerName: string; itemCount: number; completed: number; percent: number; posters: string[]; }; /** Eigene und mit einem geteilte Universen. */ export async function listUniverses(userId: string): Promise { const universes = await prisma.universe.findMany({ where: { deletedAt: null, OR: [{ ownerId: userId }, { members: { some: { userId } } }], }, orderBy: { updatedAt: "desc" }, include: { owner: { select: { name: true } }, items: { include: { title: { select: { id: true, posterPath: true, releaseDate: true, type: true } }, }, orderBy: { title: { releaseDate: "asc" } }, }, }, }); if (universes.length === 0) return []; const allTitleIds = universes.flatMap((universe) => universe.items.map((item) => item.titleId)); const completedEntries = await prisma.libraryEntry.findMany({ where: { userId, titleId: { in: allTitleIds }, status: "COMPLETED", deletedAt: null }, select: { titleId: true }, }); const completedTitles = new Set(completedEntries.map((entry) => entry.titleId)); return universes.map((universe) => { const completed = universe.items.filter((item) => completedTitles.has(item.titleId)).length; return { id: universe.id, name: universe.name, description: universe.description, isOwner: universe.ownerId === userId, ownerName: universe.owner.name, itemCount: universe.items.length, completed, percent: universe.items.length > 0 ? Math.round((completed / universe.items.length) * 100) : 0, posters: universe.items .map((item) => item.title.posterPath) .filter((path): path is string => Boolean(path)) .slice(0, 5), }; }); } /** TMDB-Collections, die in der Bibliothek des Nutzers vorkommen. */ export async function suggestCollections(userId: string) { const rows = await prisma.title.findMany({ where: { tmdbCollectionId: { not: null }, entries: { some: { userId, deletedAt: null } }, }, select: { tmdbCollectionId: true, tmdbCollectionName: true }, distinct: ["tmdbCollectionId"], orderBy: { tmdbCollectionName: "asc" }, }); return rows .filter((row) => row.tmdbCollectionId && row.tmdbCollectionName) .map((row) => ({ id: row.tmdbCollectionId!, name: row.tmdbCollectionName! })); }