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.
633 lines
20 KiB
TypeScript
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! }));
|
|
}
|