Files
flimmer/src/lib/universes.ts
T
Florian Wilde 190d7b3796 feat: Universen mit mehreren Reihenfolgen und Fortschritt
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.
2026-09-01 19:39:31 +02:00

633 lines
20 KiB
TypeScript

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<UniverseAccess> {
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<number> {
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<UniverseView | null> {
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<string, number>();
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<string, number>();
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<UniverseSummary[]> {
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! }));
}