Alles wird aus den Sichtungen berechnet — es gibt keine fortgeschriebenen Zähler, die auseinanderlaufen könnten. Zeitreihen laufen als SQL, weil Prisma keine Gruppierung nach Monat kennt. Der Monatsschlüssel kommt bewusst als Text aus der Datenbank: date_trunc liefert Mitternacht in der Sitzungszeitzone, JavaScript legt beim Vergleich die lokale Zone an — zwei Stunden Versatz genügen, damit kein Schlüssel mehr passt und das Diagramm leer bleibt.
558 lines
16 KiB
TypeScript
558 lines
16 KiB
TypeScript
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<ContinueItem[]> {
|
|
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<UpcomingEpisode[]> {
|
|
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<AvailableItem[]> {
|
|
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<string, AvailableItem>();
|
|
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<Totals> {
|
|
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<MonthlyPoint[]> {
|
|
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<Slice[]> {
|
|
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<Slice[]> {
|
|
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<TopTitle[]> {
|
|
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<TopTitle[]> {
|
|
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<YearReview> {
|
|
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<number[]> {
|
|
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);
|
|
}
|