From 190d7b3796e6cfd1fea8656c58f093adcfc0df5e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 19:39:31 +0200 Subject: [PATCH] feat: Universen mit mehreren Reihenfolgen und Fortschritt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Dinge unterscheiden ein Universum von einer Liste: es hat mehrere Reihenfolgen, und der Fortschritt ist der des jeweiligen Betrachters. Die Erscheinungsreihenfolge wird aus den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer, auch wenn später Titel dazukommen. Alles andere ist frei sortierbar. Ein geteiltes Universum zeigt jeder Person ihren eigenen Stand; bei Serien schrumpft die Restlaufzeit anteilig mit den gesehenen Episoden. --- src/lib/universes.ts | 632 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 632 insertions(+) create mode 100644 src/lib/universes.ts diff --git a/src/lib/universes.ts b/src/lib/universes.ts new file mode 100644 index 0000000..486b189 --- /dev/null +++ b/src/lib/universes.ts @@ -0,0 +1,632 @@ +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 }, + }); + await requireWrite(userId, order.universeId); + if (order.kind === "RELEASE") { + throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu."); + } + await prisma.universeOrder.delete({ where: { id: orderId } }); +} + +/** 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! })); +}