import "server-only"; import { prisma } from "./db"; import { SPECIALS_SEASON } from "./catalog"; import type { EntryStatus, Prisma, TitleType } from "@/generated/prisma/client"; /** * Leseabfragen für die Bibliothek. * * Der Fortschritt wird für eine ganze Liste in **einer** Abfrage gezählt statt * pro Eintrag — bei 300 Titeln wären das sonst 300 Rundreisen zur Datenbank. */ export type LibrarySort = "aktualisiert" | "titel" | "bewertung" | "jahr" | "hinzugefuegt"; export type LibraryFilters = { query?: string; type?: TitleType; status?: EntryStatus; genre?: string; tag?: string; provider?: string; /** Nur Titel, die in der eigenen Sammlung liegen. */ owned?: boolean; sort?: LibrarySort; }; const ORDER: Record = { aktualisiert: [{ updatedAt: "desc" }], hinzugefuegt: [{ createdAt: "desc" }], titel: [{ title: { title: "asc" } }], bewertung: [{ rating: { sort: "desc", nulls: "last" } }, { title: { title: "asc" } }], jahr: [{ title: { releaseDate: { sort: "desc", nulls: "last" } } }], }; export type LibraryItem = { entryId: string; titleId: string; title: string; type: TitleType; posterPath: string | null; year: number | null; status: EntryStatus; rating: number | null; favorite: boolean; currentRun: number; whereToWatch: string | null; owned: boolean; ownedSource: string | null; tags: string[]; watched: number; total: number; percent: number; }; export async function getLibrary( userId: string, filters: LibraryFilters = {}, ): Promise { const where: Prisma.LibraryEntryWhereInput = { 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 } : {}), ...(filters.genre ? { genres: { has: filters.genre } } : {}), ...(filters.provider ? { providers: { some: { provider: { name: filters.provider } } } } : {}), ...(filters.query ? { OR: [ { title: { contains: filters.query, mode: "insensitive" } }, { originalTitle: { contains: filters.query, mode: "insensitive" } }, ], } : {}), }, }; const entries = await prisma.libraryEntry.findMany({ where, orderBy: ORDER[filters.sort ?? "aktualisiert"], include: { title: { select: { id: true, title: true, type: true, posterPath: true, releaseDate: true, episodeCount: true, }, }, tags: { include: { tag: { select: { name: true } } } }, }, }); if (entries.length === 0) return []; const counts = await countWatchedByEntry( userId, entries.map((entry) => ({ id: entry.id, run: entry.currentRun })), ); return entries.map((entry) => { const isSeries = entry.title.type === "SERIES"; const total = isSeries ? entry.title.episodeCount : 1; const watched = Math.min(counts.get(entry.id) ?? 0, total); return { entryId: entry.id, titleId: entry.title.id, title: entry.title.title, type: entry.title.type, posterPath: entry.title.posterPath, year: entry.title.releaseDate?.getFullYear() ?? null, status: entry.status, rating: entry.rating, 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, percent: total > 0 ? Math.round((watched / total) * 100) : 0, }; }); } /** * Zählt für viele Einträge auf einmal, wie viel im jeweils aktuellen * Durchlauf gesehen wurde. Specials bleiben außen vor. */ async function countWatchedByEntry( userId: string, entries: { id: string; run: number }[], ): Promise> { if (entries.length === 0) return new Map(); const rows = await prisma.watch.groupBy({ by: ["entryId", "run"], where: { userId, entryId: { in: entries.map((entry) => entry.id) }, OR: [{ episodeId: null }, { episode: { seasonNumber: { gt: SPECIALS_SEASON } } }], }, _count: { _all: true }, }); const runByEntry = new Map(entries.map((entry) => [entry.id, entry.run])); const result = new Map(); for (const row of rows) { if (!row.entryId) continue; // Nur der aktuelle Durchlauf zählt für den Fortschrittsbalken. if (runByEntry.get(row.entryId) !== row.run) continue; result.set(row.entryId, row._count._all); } return result; } export type LibraryFacets = { genres: string[]; 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, owned] = await Promise.all([ prisma.$queryRaw<{ genre: string }[]>` SELECT DISTINCT unnest(t."genres") AS genre FROM "LibraryEntry" e JOIN "Title" t ON t."id" = e."titleId" WHERE e."userId" = ${userId} AND e."deletedAt" IS NULL ORDER BY genre `, prisma.tag.findMany({ where: { userId, entries: { some: { entry: { deletedAt: null } } } }, orderBy: { name: "asc" }, select: { name: true }, }), prisma.$queryRaw<{ name: string }[]>` SELECT DISTINCT p."name" FROM "LibraryEntry" e JOIN "TitleProvider" tp ON tp."titleId" = e."titleId" JOIN "WatchProvider" p ON p."id" = tp."providerId" WHERE e."userId" = ${userId} AND e."deletedAt" IS NULL ORDER BY p."name" `, prisma.libraryEntry.groupBy({ by: ["status"], where: { userId, deletedAt: null }, _count: { _all: true }, }), prisma.libraryEntry.count({ where: { userId, deletedAt: null } }), prisma.libraryEntry.count({ where: { userId, deletedAt: null, owned: true } }), ]); const counts = { ALLE: total, WATCHING: 0, PAUSED: 0, COMPLETED: 0, DROPPED: 0, WATCHLIST: 0, } as Record; for (const row of statusRows) counts[row.status] = row._count._all; return { genres: genreRows.map((row) => row.genre).filter(Boolean), tags: tagRows.map((row) => row.name), providers: providerRows.map((row) => row.name), counts, owned, }; } /** Einträge im Papierkorb, jüngste zuerst. */ export async function getTrash(userId: string) { return prisma.libraryEntry.findMany({ where: { userId, deletedAt: { not: null } }, orderBy: { deletedAt: "desc" }, include: { title: { select: { id: true, title: true, type: true, posterPath: true, releaseDate: true } }, }, }); }