feat: Kern-Tracking mit Sichtungen, Fortschritt und Rewatches

Grundgedanke: Eine Sichtung ist die einzige Wahrheit darüber, was jemand
gesehen hat. Fortschritt, „nächste Episode" und später die Statistik werden
daraus berechnet, nichts davon wird doppelt gespeichert.

Rewatches zählen über einen Durchlaufzähler am Eintrag. Ein neuer Durchlauf
setzt den Fortschritt zurück, die alten Sichtungen bleiben stehen — sonst
verlöre die Schauzeit-Statistik jedes Mal ihre Geschichte.

Der Status ergibt sich aus den Sichtungen: die erste Episode startet, die
letzte schließt ab. Pausiert und Abgebrochen bleiben unangetastet — wer
bewusst pausiert hat, will das nicht durch ein Häkchen aufgehoben bekommen.
This commit is contained in:
Florian Wilde
2026-09-01 19:24:49 +02:00
parent 9ecaaf03a9
commit bb6ce4ab35
3 changed files with 836 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
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;
sort?: LibrarySort;
};
const ORDER: Record<LibrarySort, Prisma.LibraryEntryOrderByWithRelationInput[]> = {
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;
tags: string[];
watched: number;
total: number;
percent: number;
};
export async function getLibrary(
userId: string,
filters: LibraryFilters = {},
): Promise<LibraryItem[]> {
const where: Prisma.LibraryEntryWhereInput = {
userId,
deletedAt: null,
...(filters.status ? { status: filters.status } : {}),
...(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,
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<Map<string, number>> {
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<string, number>();
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<EntryStatus | "ALLE", number>;
};
/** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */
export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
const [genreRows, tagRows, providerRows, statusRows, total] = 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 } }),
]);
const counts = {
ALLE: total,
WATCHING: 0,
PAUSED: 0,
COMPLETED: 0,
DROPPED: 0,
WATCHLIST: 0,
} as Record<EntryStatus | "ALLE", number>;
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,
};
}
/** 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 } },
},
});
}