import "server-only"; import { prisma } from "./db"; import { SPECIALS_SEASON } from "./catalog"; /** * Auswertungen für Übersicht, Kalender und Statistik. * * Alles wird aus den Sichtungen (`Watch`) berechnet — es gibt keine * fortgeschriebenen Zähler, die auseinanderlaufen könnten. Wo es auf Tempo * ankommt, laufen die Abfragen als SQL, weil Prisma für Zeitreihen keine * Gruppierung nach Monat kennt. */ // --------------------------------------------------------------------------- // Übersicht // --------------------------------------------------------------------------- export type ContinueItem = { entryId: string; titleId: string; title: string; posterPath: string | null; watched: number; total: number; percent: number; lastWatchedAt: Date | null; next: { id: string; name: string; seasonNumber: number; episodeNumber: number; airDate: Date | null; runtime: number | null; } | null; }; /** * „Weiterschauen": laufende Serien, zuletzt angefasste zuerst. * Serien ohne offene Episode fallen heraus — die sind durch. */ export async function getContinueWatching(userId: string, limit = 12): Promise { const entries = await prisma.libraryEntry.findMany({ where: { userId, deletedAt: null, status: { in: ["WATCHING", "PAUSED"] }, title: { type: "SERIES" }, }, orderBy: { updatedAt: "desc" }, take: limit * 2, select: { id: true, titleId: true, currentRun: true, updatedAt: true, title: { select: { title: true, posterPath: true, episodeCount: true } }, }, }); if (entries.length === 0) return []; const items: ContinueItem[] = []; for (const entry of entries) { const [watched, next, lastWatch] = await Promise.all([ prisma.watch.count({ where: { userId, entryId: entry.id, run: entry.currentRun, episode: { seasonNumber: { gt: SPECIALS_SEASON } }, }, }), prisma.episode.findFirst({ where: { titleId: entry.titleId, seasonNumber: { gt: SPECIALS_SEASON }, watches: { none: { userId, run: entry.currentRun } }, }, orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }], select: { id: true, name: true, seasonNumber: true, episodeNumber: true, airDate: true, runtime: true, }, }), prisma.watch.findFirst({ where: { userId, entryId: entry.id, run: entry.currentRun }, orderBy: { watchedAt: "desc" }, select: { watchedAt: true }, }), ]); if (!next) continue; const total = entry.title.episodeCount; items.push({ entryId: entry.id, titleId: entry.titleId, title: entry.title.title, posterPath: entry.title.posterPath, watched, total, percent: total > 0 ? Math.round((watched / total) * 100) : 0, lastWatchedAt: lastWatch?.watchedAt ?? null, next, }); if (items.length >= limit) break; } return items; } export type UpcomingEpisode = { episodeId: string; titleId: string; title: string; posterPath: string | null; name: string; seasonNumber: number; episodeNumber: number; airDate: Date; runtime: number | null; }; /** * Kommende Episoden getrackter Serien. * * „Getrackt" heißt: in der Bibliothek und nicht abgebrochen. Abgeschlossene * Serien bleiben bewusst drin — wer bei einer laufenden Serie auf dem Stand * ist, gilt als „abgeschlossen" und will die nächste Folge erst recht im * Kalender sehen. Nur Abgebrochenes fällt heraus. */ export async function getUpcomingEpisodes( userId: string, from: Date, to: Date, ): Promise { const episodes = await prisma.episode.findMany({ where: { airDate: { gte: from, lte: to }, seasonNumber: { gt: SPECIALS_SEASON }, title: { entries: { some: { userId, deletedAt: null, status: { not: "DROPPED" } }, }, }, }, orderBy: [{ airDate: "asc" }, { seasonNumber: "asc" }, { episodeNumber: "asc" }], select: { id: true, name: true, seasonNumber: true, episodeNumber: true, airDate: true, runtime: true, title: { select: { id: true, title: true, posterPath: true } }, }, }); return episodes.flatMap((episode) => episode.airDate ? [ { episodeId: episode.id, titleId: episode.title.id, title: episode.title.title, posterPath: episode.title.posterPath, name: episode.name, seasonNumber: episode.seasonNumber, episodeNumber: episode.episodeNumber, airDate: episode.airDate, runtime: episode.runtime, }, ] : [], ); } export type AvailableItem = { titleId: string; title: string; posterPath: string | null; providers: { name: string; color: string | null }[]; since: Date; }; /** * Watchlist-Titel, die seit Kurzem auf einem Dienst verfügbar sind. * Grundlage ist `firstSeenAt` der Verfügbarkeit — das steht seit dem ersten * Abgleich fest und wandert nicht bei jedem Sync weiter. */ export async function getNewlyAvailable( userId: string, region: string, sinceDays = 30, ): Promise { const since = new Date(Date.now() - sinceDays * 86_400_000); const rows = await prisma.titleProvider.findMany({ where: { region, kind: { in: ["FLATRATE", "FREE", "ADS"] }, firstSeenAt: { gte: since }, title: { entries: { some: { userId, deletedAt: null, status: "WATCHLIST" } } }, }, orderBy: { firstSeenAt: "desc" }, select: { firstSeenAt: true, provider: { select: { name: true, color: true } }, title: { select: { id: true, title: true, posterPath: true } }, }, }); const byTitle = new Map(); for (const row of rows) { const existing = byTitle.get(row.title.id); if (existing) { if (!existing.providers.some((provider) => provider.name === row.provider.name)) { existing.providers.push(row.provider); } continue; } byTitle.set(row.title.id, { titleId: row.title.id, title: row.title.title, posterPath: row.title.posterPath, providers: [row.provider], since: row.firstSeenAt, }); } return [...byTitle.values()]; } // --------------------------------------------------------------------------- // Statistik // --------------------------------------------------------------------------- export type Totals = { minutes: number; episodes: number; movies: number; titlesCompleted: number; seriesCount: number; movieCount: number; }; export async function getTotals(userId: string): Promise { const [minutes, episodes, movies, completed, byType] = await Promise.all([ prisma.watch.aggregate({ where: { userId }, _sum: { minutes: true } }), prisma.watch.count({ where: { userId, episodeId: { not: null } } }), prisma.watch.count({ where: { userId, episodeId: null } }), prisma.libraryEntry.count({ where: { userId, deletedAt: null, status: "COMPLETED" } }), prisma.libraryEntry.groupBy({ by: ["titleId"], where: { userId, deletedAt: null }, _count: { _all: true }, }), ]); const typeCounts = await prisma.title.groupBy({ by: ["type"], where: { id: { in: byType.map((row) => row.titleId) } }, _count: { _all: true }, }); return { minutes: minutes._sum.minutes ?? 0, episodes, movies, titlesCompleted: completed, seriesCount: typeCounts.find((row) => row.type === "SERIES")?._count._all ?? 0, movieCount: typeCounts.find((row) => row.type === "MOVIE")?._count._all ?? 0, }; } export type MonthlyPoint = { /** Monatsanfang */ month: Date; label: string; episodes: number; movies: number; minutes: number; }; /** Aktivität je Monat, lückenlos — auch Monate ohne Sichtung erscheinen. */ export async function getMonthlyActivity(userId: string, months = 12): Promise { const start = new Date(); start.setDate(1); start.setHours(0, 0, 0, 0); start.setMonth(start.getMonth() - (months - 1)); // Der Monatsschlüssel kommt als Text aus der Datenbank („2026-04"). // Ein Zeitstempel würde hier schiefgehen: date_trunc liefert Mitternacht in // der Sitzungszeitzone, JS legt beim Vergleich die lokale Zone an — zwei // Stunden Versatz genügen, damit kein einziger Schlüssel mehr passt. const rows = await prisma.$queryRaw< { month: string; episodes: bigint; movies: bigint; minutes: bigint | null }[] >` SELECT to_char(date_trunc('month', "watchedAt"), 'YYYY-MM') AS month, count(*) FILTER (WHERE "episodeId" IS NOT NULL) AS episodes, count(*) FILTER (WHERE "episodeId" IS NULL) AS movies, sum("minutes") AS minutes FROM "Watch" WHERE "userId" = ${userId} AND "watchedAt" >= ${start} GROUP BY 1 ORDER BY 1 `; const byMonth = new Map(rows.map((row) => [row.month, row])); const points: MonthlyPoint[] = []; for (let index = 0; index < months; index += 1) { const month = new Date(start); month.setMonth(start.getMonth() + index); const key = `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, "0")}`; const row = byMonth.get(key); points.push({ month, label: month.toLocaleDateString("de-DE", { month: "short" }), episodes: Number(row?.episodes ?? 0), movies: Number(row?.movies ?? 0), minutes: Number(row?.minutes ?? 0), }); } return points; } export type Slice = { label: string; minutes: number; count: number; color: string | null }; /** * Verteilung der Schauzeit nach Streamingdienst. * Grundlage ist das Feld „wo schaue ich das" — es sagt, wo tatsächlich * geschaut wurde, nicht bloß, wo es verfügbar wäre. */ export async function getProviderDistribution(userId: string): Promise { const rows = await prisma.$queryRaw<{ label: string; minutes: bigint; count: bigint }[]>` SELECT coalesce(nullif(e."whereToWatch", ''), 'Ohne Angabe') AS label, sum(w."minutes") AS minutes, count(*) AS count FROM "Watch" w JOIN "LibraryEntry" e ON e."id" = w."entryId" WHERE w."userId" = ${userId} GROUP BY 1 ORDER BY minutes DESC `; const providers = await prisma.watchProvider.findMany({ select: { name: true, color: true } }); const colorByName = new Map(providers.map((provider) => [provider.name, provider.color])); return rows.map((row) => ({ label: row.label, minutes: Number(row.minutes ?? 0), count: Number(row.count), color: colorByName.get(row.label) ?? null, })); } /** Verteilung der Schauzeit nach Genre. Mehrfachnennungen zählen mehrfach. */ export async function getGenreDistribution(userId: string): Promise { const rows = await prisma.$queryRaw<{ label: string; minutes: bigint; count: bigint }[]>` SELECT genre AS label, sum(w."minutes") AS minutes, count(*) AS count FROM "Watch" w JOIN "Title" t ON t."id" = w."titleId" CROSS JOIN LATERAL unnest(t."genres") AS genre WHERE w."userId" = ${userId} GROUP BY 1 ORDER BY minutes DESC LIMIT 12 `; return rows.map((row) => ({ label: row.label, minutes: Number(row.minutes ?? 0), count: Number(row.count), color: null, })); } export type TopTitle = { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number; type: "SERIES" | "MOVIE"; }; /** Bestbewertete Titel. */ export async function getTopRated(userId: string, limit = 6): Promise { const entries = await prisma.libraryEntry.findMany({ where: { userId, deletedAt: null, rating: { not: null } }, orderBy: [{ rating: "desc" }, { updatedAt: "desc" }], take: limit, select: { rating: true, title: { select: { id: true, title: true, posterPath: true, type: true } }, }, }); return entries.map((entry) => ({ titleId: entry.title.id, title: entry.title.title, posterPath: entry.title.posterPath, rating: entry.rating, minutes: 0, type: entry.title.type, })); } /** Titel mit der meisten investierten Zeit. */ export async function getMostWatched(userId: string, limit = 6): Promise { const rows = await prisma.watch.groupBy({ by: ["titleId"], where: { userId }, _sum: { minutes: true }, orderBy: { _sum: { minutes: "desc" } }, take: limit, }); if (rows.length === 0) return []; const titles = await prisma.title.findMany({ where: { id: { in: rows.map((row) => row.titleId) } }, select: { id: true, title: true, posterPath: true, type: true }, }); const byId = new Map(titles.map((title) => [title.id, title])); return rows.flatMap((row) => { const title = byId.get(row.titleId); return title ? [ { titleId: title.id, title: title.title, posterPath: title.posterPath, rating: null, minutes: row._sum.minutes ?? 0, type: title.type, }, ] : []; }); } export type YearReview = { year: number; minutes: number; episodes: number; movies: number; titlesStarted: number; titlesCompleted: number; busiestMonth: { label: string; minutes: number } | null; topTitle: TopTitle | null; }; /** „Das Jahr in Zahlen". */ export async function getYearReview(userId: string, year: number): Promise { const from = new Date(Date.UTC(year, 0, 1)); const to = new Date(Date.UTC(year + 1, 0, 1)); const [aggregate, episodes, movies, started, completed, months, top] = await Promise.all([ prisma.watch.aggregate({ where: { userId, watchedAt: { gte: from, lt: to } }, _sum: { minutes: true }, }), prisma.watch.count({ where: { userId, watchedAt: { gte: from, lt: to }, episodeId: { not: null } }, }), prisma.watch.count({ where: { userId, watchedAt: { gte: from, lt: to }, episodeId: null } }), prisma.libraryEntry.count({ where: { userId, startedAt: { gte: from, lt: to } } }), prisma.libraryEntry.count({ where: { userId, finishedAt: { gte: from, lt: to } } }), prisma.$queryRaw<{ month: string; minutes: bigint }[]>` SELECT to_char(date_trunc('month', "watchedAt"), 'YYYY-MM') AS month, sum("minutes") AS minutes FROM "Watch" WHERE "userId" = ${userId} AND "watchedAt" >= ${from} AND "watchedAt" < ${to} GROUP BY 1 ORDER BY minutes DESC LIMIT 1 `, prisma.watch.groupBy({ by: ["titleId"], where: { userId, watchedAt: { gte: from, lt: to } }, _sum: { minutes: true }, orderBy: { _sum: { minutes: "desc" } }, take: 1, }), ]); const busiest = months[0]; const topTitleRow = top[0]; const topTitle = topTitleRow ? await prisma.title.findUnique({ where: { id: topTitleRow.titleId }, select: { id: true, title: true, posterPath: true, type: true }, }) : null; return { year, minutes: aggregate._sum.minutes ?? 0, episodes, movies, titlesStarted: started, titlesCompleted: completed, busiestMonth: busiest ? { label: monthLabel(busiest.month), minutes: Number(busiest.minutes ?? 0), } : null, topTitle: topTitle && topTitleRow ? { titleId: topTitle.id, title: topTitle.title, posterPath: topTitle.posterPath, rating: null, minutes: topTitleRow._sum.minutes ?? 0, type: topTitle.type, } : null, }; } /** „2026-04" → „April 2026" */ function monthLabel(key: string): string { const [year, month] = key.split("-").map(Number); if (!year || !month) return key; return new Date(year, month - 1, 1).toLocaleDateString("de-DE", { month: "long", year: "numeric", }); } /** Jahre, in denen überhaupt etwas gesehen wurde — für die Jahresauswahl. */ export async function getActiveYears(userId: string): Promise { const rows = await prisma.$queryRaw<{ year: number }[]>` SELECT DISTINCT extract(year FROM "watchedAt")::int AS year FROM "Watch" WHERE "userId" = ${userId} ORDER BY year DESC `; return rows.map((row) => row.year); }