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
+101
View File
@@ -0,0 +1,101 @@
/**
* Beschriftungen und Formatierungen, die im Server- wie im Browsercode
* gebraucht werden. Bewusst frei von Server-Abhängigkeiten.
*/
export const STATUS_LABEL = {
WATCHING: "Schaue gerade",
PAUSED: "Pausiert",
COMPLETED: "Abgeschlossen",
DROPPED: "Abgebrochen",
WATCHLIST: "Watchlist",
} as const;
export type StatusKey = keyof typeof STATUS_LABEL;
export const STATUS_ORDER: StatusKey[] = [
"WATCHING",
"WATCHLIST",
"PAUSED",
"COMPLETED",
"DROPPED",
];
/** Farbtoken je Status — als CSS-Variable, damit Dark und Light beide stimmen. */
export const STATUS_COLOR: Record<StatusKey, string> = {
WATCHING: "var(--fl-accent-alt)",
WATCHLIST: "var(--fl-info)",
PAUSED: "var(--fl-warning)",
COMPLETED: "var(--fl-success)",
DROPPED: "var(--fl-faint)",
};
export const TYPE_LABEL = { SERIES: "Serie", MOVIE: "Film" } as const;
/** „S03E05" */
export function episodeCode(seasonNumber: number, episodeNumber: number): string {
return `S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}`;
}
/**
* Minuten menschenlesbar: „48 min", „3 h 20 min", „5 Tage 4 h".
* Ab einem Tag wird gerundet — auf die Minute genau interessiert dann niemanden.
*/
export function formatMinutes(minutes: number): string {
if (!Number.isFinite(minutes) || minutes <= 0) return "0 min";
const days = Math.floor(minutes / (60 * 24));
const hours = Math.floor((minutes % (60 * 24)) / 60);
const rest = Math.round(minutes % 60);
if (days > 0) {
return hours > 0
? `${days} ${days === 1 ? "Tag" : "Tage"} ${hours} h`
: `${days} ${days === 1 ? "Tag" : "Tage"}`;
}
if (hours > 0) return rest > 0 ? `${hours} h ${rest} min` : `${hours} h`;
return `${rest} min`;
}
export function formatDate(value: Date | string | null | undefined): string | null {
if (!value) return null;
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
}
export function formatDateLong(value: Date | string | null | undefined): string | null {
if (!value) return null;
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString("de-DE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
}
/** „vor 3 Tagen", „heute", „in 2 Wochen" */
export function formatRelative(value: Date | string | null | undefined): string | null {
if (!value) return null;
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return null;
const diffDays = Math.round((date.getTime() - Date.now()) / 86_400_000);
if (diffDays === 0) return "heute";
if (diffDays === 1) return "morgen";
if (diffDays === -1) return "gestern";
const formatter = new Intl.RelativeTimeFormat("de-DE", { numeric: "auto" });
if (Math.abs(diffDays) < 30) return formatter.format(diffDays, "day");
if (Math.abs(diffDays) < 365) return formatter.format(Math.round(diffDays / 30), "month");
return formatter.format(Math.round(diffDays / 365), "year");
}
/** Ist das Datum in der Zukunft? (Für „noch nicht ausgestrahlt".) */
export function isUpcoming(value: Date | string | null | undefined): boolean {
if (!value) return false;
const date = typeof value === "string" ? new Date(value) : value;
return !Number.isNaN(date.getTime()) && date.getTime() > Date.now();
}
+509
View File
@@ -0,0 +1,509 @@
import "server-only";
import { prisma } from "./db";
import { SPECIALS_SEASON } from "./catalog";
import type { EntryStatus, LibraryEntry, Prisma, TitleType } from "@/generated/prisma/client";
/**
* Persönliches Tracking: Bibliothek, Status, Sichtungen, Fortschritt.
*
* Grundgedanke: Eine **Sichtung** (`Watch`) ist die einzige Wahrheit darüber,
* was jemand gesehen hat. Fortschritt, Statistik und „nächste Episode" werden
* daraus berechnet, nichts davon wird doppelt gespeichert. Rewatches zählen
* über `run` — Durchlauf 0 ist die Erstsichtung.
*/
export class LibraryError extends Error {}
// ---------------------------------------------------------------------------
// Aufnehmen und entfernen
// ---------------------------------------------------------------------------
/** Nimmt einen Titel in die Bibliothek auf oder gibt den vorhandenen Eintrag zurück. */
export async function addToLibrary(
userId: string,
titleId: string,
status: EntryStatus = "WATCHLIST",
): Promise<LibraryEntry> {
const existing = await prisma.libraryEntry.findUnique({
where: { userId_titleId: { userId, titleId } },
});
// Ein Titel im Papierkorb wird beim erneuten Aufnehmen wiederbelebt,
// statt einen zweiten Eintrag anzulegen — der Fortschritt bleibt erhalten.
if (existing) {
if (!existing.deletedAt) return existing;
return prisma.libraryEntry.update({
where: { id: existing.id },
data: { deletedAt: null, status },
});
}
const suggestion = await suggestWhereToWatch(userId, titleId);
return prisma.libraryEntry.create({
data: {
userId,
titleId,
status,
whereToWatch: suggestion,
startedAt: status === "WATCHING" ? new Date() : null,
},
});
}
/**
* Vorschlag für „wo schaue ich das": der erste Abo-Dienst in der Region des
* Nutzers. Rein als Startwert — überschreiben lässt sich das Feld jederzeit.
*/
async function suggestWhereToWatch(userId: string, titleId: string): Promise<string | null> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { region: true } });
if (!user) return null;
const provider = await prisma.titleProvider.findFirst({
where: { titleId, region: user.region, kind: { in: ["FLATRATE", "FREE", "ADS"] } },
include: { provider: { select: { name: true } } },
orderBy: { kind: "asc" },
});
return provider?.provider.name ?? null;
}
/** In den Papierkorb legen — 30 Tage lang wiederherstellbar. */
export async function moveToTrash(userId: string, entryId: string) {
return prisma.libraryEntry.update({
where: { id: entryId, userId },
data: { deletedAt: new Date() },
});
}
export async function restoreFromTrash(userId: string, entryId: string) {
return prisma.libraryEntry.update({
where: { id: entryId, userId },
data: { deletedAt: null },
});
}
/** Endgültig löschen. Die Sichtungen gehen dabei mit verloren. */
export async function purgeEntry(userId: string, entryId: string) {
await prisma.watch.deleteMany({ where: { userId, entryId } });
await prisma.libraryEntry.delete({ where: { id: entryId, userId } });
}
// ---------------------------------------------------------------------------
// Status und Angaben
// ---------------------------------------------------------------------------
export async function setStatus(userId: string, entryId: string, status: EntryStatus) {
const entry = await requireEntry(userId, entryId);
const data: Prisma.LibraryEntryUpdateInput = { status };
if (status === "WATCHING" && !entry.startedAt) data.startedAt = new Date();
if (status === "COMPLETED") data.finishedAt = new Date();
if (status === "WATCHLIST") {
data.startedAt = null;
data.finishedAt = null;
}
return prisma.libraryEntry.update({ where: { id: entryId }, data });
}
export async function updateEntryDetails(
userId: string,
entryId: string,
data: { rating?: number | null; notes?: string | null; whereToWatch?: string | null; favorite?: boolean },
) {
await requireEntry(userId, entryId);
if (data.rating != null && (data.rating < 1 || data.rating > 10)) {
throw new LibraryError("Die Bewertung muss zwischen 1 und 10 liegen.");
}
return prisma.libraryEntry.update({
where: { id: entryId },
data: {
...(data.rating !== undefined ? { rating: data.rating } : {}),
...(data.notes !== undefined ? { notes: data.notes?.trim() || null } : {}),
...(data.whereToWatch !== undefined
? { whereToWatch: data.whereToWatch?.trim() || null }
: {}),
...(data.favorite !== undefined ? { favorite: data.favorite } : {}),
},
});
}
async function requireEntry(userId: string, entryId: string) {
const entry = await prisma.libraryEntry.findFirst({
where: { id: entryId, userId },
include: { title: { select: { id: true, type: true, runtime: true, episodeCount: true } } },
});
if (!entry) throw new LibraryError("Dieser Eintrag gehört nicht zu deiner Bibliothek.");
return entry;
}
// ---------------------------------------------------------------------------
// Sichtungen
// ---------------------------------------------------------------------------
/**
* Ergebnis einer Sichtungsaktion.
*
* `watchIds` trägt die neu angelegten Sichtungen — damit lässt sich die
* Aktion mit einem Klick zurücknehmen, ohne raten zu müssen, was dazugehörte.
*/
export type WatchResult = {
watchIds: string[];
status: EntryStatus;
label: string;
};
/** Markiert einzelne Episoden als gesehen (im aktuellen Durchlauf). */
export async function markEpisodesWatched(
userId: string,
entryId: string,
episodeIds: string[],
watchedAt = new Date(),
): Promise<WatchResult> {
const entry = await requireEntry(userId, entryId);
if (entry.title.type !== "SERIES") {
throw new LibraryError("Episoden gibt es nur bei Serien.");
}
const episodes = await prisma.episode.findMany({
where: { id: { in: episodeIds }, titleId: entry.titleId },
select: { id: true, runtime: true, seasonNumber: true, episodeNumber: true },
});
const alreadySeen = new Set(
(
await prisma.watch.findMany({
where: { userId, episodeId: { in: episodes.map((e) => e.id) }, run: entry.currentRun },
select: { episodeId: true },
})
).map((watch) => watch.episodeId),
);
const toCreate = episodes.filter((episode) => !alreadySeen.has(episode.id));
const created = await prisma.$transaction(
toCreate.map((episode) =>
prisma.watch.create({
data: {
userId,
entryId,
titleId: entry.titleId,
episodeId: episode.id,
run: entry.currentRun,
watchedAt,
// Laufzeit wird eingefroren: spätere TMDB-Korrekturen sollen die
// bereits gezählte Schauzeit nicht rückwirkend verbiegen.
minutes: episode.runtime ?? entry.title.runtime ?? 0,
},
select: { id: true },
}),
),
);
const status = await reconcileStatus(userId, entryId);
return {
watchIds: created.map((watch) => watch.id),
status,
label:
toCreate.length === 1
? "Episode als gesehen markiert"
: `${toCreate.length} Episoden als gesehen markiert`,
};
}
/** Nimmt die Markierung einzelner Episoden im aktuellen Durchlauf zurück. */
export async function unmarkEpisodes(userId: string, entryId: string, episodeIds: string[]) {
const entry = await requireEntry(userId, entryId);
await prisma.watch.deleteMany({
where: { userId, entryId, episodeId: { in: episodeIds }, run: entry.currentRun },
});
return reconcileStatus(userId, entryId);
}
/** Ganze Staffel abhaken. */
export async function markSeasonWatched(userId: string, entryId: string, seasonId: string) {
const entry = await requireEntry(userId, entryId);
const episodes = await prisma.episode.findMany({
where: { seasonId, titleId: entry.titleId },
select: { id: true },
});
return markEpisodesWatched(
userId,
entryId,
episodes.map((episode) => episode.id),
);
}
export async function unmarkSeason(userId: string, entryId: string, seasonId: string) {
const entry = await requireEntry(userId, entryId);
const episodes = await prisma.episode.findMany({
where: { seasonId, titleId: entry.titleId },
select: { id: true },
});
return unmarkEpisodes(
userId,
entryId,
episodes.map((episode) => episode.id),
);
}
/**
* „Bis hierhin gesehen": markiert die Episode und alles davor.
* Praktisch, wenn jemand mitten in einer Staffel einsteigt.
*/
export async function markUpTo(userId: string, entryId: string, episodeId: string) {
const entry = await requireEntry(userId, entryId);
const target = await prisma.episode.findFirst({
where: { id: episodeId, titleId: entry.titleId },
select: { seasonNumber: true, episodeNumber: true },
});
if (!target) throw new LibraryError("Diese Episode gehört nicht zu dem Titel.");
const episodes = await prisma.episode.findMany({
where: {
titleId: entry.titleId,
seasonNumber: { gt: SPECIALS_SEASON },
OR: [
{ seasonNumber: { lt: target.seasonNumber } },
{ seasonNumber: target.seasonNumber, episodeNumber: { lte: target.episodeNumber } },
],
},
select: { id: true },
});
return markEpisodesWatched(
userId,
entryId,
episodes.map((episode) => episode.id),
);
}
/** „+1 Episode" — hakt genau die nächste offene Episode ab. */
export async function watchNextEpisode(userId: string, entryId: string): Promise<WatchResult> {
const next = await getNextEpisode(userId, entryId);
if (!next) throw new LibraryError("Es sind keine offenen Episoden mehr da.");
const result = await markEpisodesWatched(userId, entryId, [next.id]);
return {
...result,
label: `S${String(next.seasonNumber).padStart(2, "0")}E${String(next.episodeNumber).padStart(2, "0")} abgehakt`,
};
}
/** Film als gesehen markieren. */
export async function markMovieWatched(
userId: string,
entryId: string,
watchedAt = new Date(),
): Promise<WatchResult> {
const entry = await requireEntry(userId, entryId);
if (entry.title.type !== "MOVIE") throw new LibraryError("Das ist keine Filmsichtung.");
const existing = await prisma.watch.findFirst({
where: { userId, entryId, episodeId: null, run: entry.currentRun },
select: { id: true },
});
if (existing) {
return { watchIds: [], status: entry.status, label: "War schon als gesehen markiert" };
}
const watch = await prisma.watch.create({
data: {
userId,
entryId,
titleId: entry.titleId,
run: entry.currentRun,
watchedAt,
minutes: entry.title.runtime ?? 0,
},
select: { id: true },
});
const status = await reconcileStatus(userId, entryId);
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" };
}
/** Macht eine Sichtungsaktion rückgängig. */
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
await requireEntry(userId, entryId);
await prisma.watch.deleteMany({ where: { id: { in: watchIds }, userId, entryId } });
return reconcileStatus(userId, entryId);
}
/**
* Neuen Durchlauf beginnen.
*
* Die bisherigen Sichtungen bleiben stehen — sie gehören zum vorherigen
* Durchlauf und zählen weiter in die Statistik. Der Fortschritt startet neu.
*/
export async function startRewatch(userId: string, entryId: string) {
const entry = await requireEntry(userId, entryId);
return prisma.libraryEntry.update({
where: { id: entryId },
data: {
currentRun: entry.currentRun + 1,
status: "WATCHING",
startedAt: new Date(),
finishedAt: null,
},
});
}
/**
* Bringt den Status in Einklang mit den Sichtungen:
* erste Sichtung startet, die letzte schließt ab.
* Manuell gesetzte Zustände (Pausiert, Abgebrochen) bleiben unangetastet.
*/
async function reconcileStatus(userId: string, entryId: string): Promise<EntryStatus> {
const entry = await requireEntry(userId, entryId);
const { watched, total } = await countProgress(userId, entry.id, entry.titleId, entry.title.type, entry.currentRun);
if (entry.status === "PAUSED" || entry.status === "DROPPED") return entry.status;
let status: EntryStatus = entry.status;
const data: Prisma.LibraryEntryUpdateInput = {};
if (total > 0 && watched >= total) {
status = "COMPLETED";
data.finishedAt = entry.finishedAt ?? new Date();
} else if (watched > 0) {
status = "WATCHING";
data.finishedAt = null;
}
if (watched > 0 && !entry.startedAt) data.startedAt = new Date();
if (status !== entry.status || Object.keys(data).length > 0) {
await prisma.libraryEntry.update({ where: { id: entryId }, data: { ...data, status } });
}
return status;
}
// ---------------------------------------------------------------------------
// Fortschritt
// ---------------------------------------------------------------------------
async function countProgress(
userId: string,
entryId: string,
titleId: string,
type: TitleType,
run: number,
) {
if (type === "MOVIE") {
const watched = await prisma.watch.count({
where: { userId, entryId, episodeId: null, run },
});
return { watched: Math.min(watched, 1), total: 1 };
}
const [watched, total] = await Promise.all([
prisma.watch.count({
where: {
userId,
entryId,
run,
episode: { seasonNumber: { gt: SPECIALS_SEASON } },
},
}),
prisma.episode.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
]);
return { watched, total };
}
export type Progress = {
watched: number;
total: number;
percent: number;
minutesWatched: number;
/** Verbleibende Laufzeit des aktuellen Durchlaufs in Minuten. */
minutesLeft: number;
};
export async function getProgress(userId: string, entryId: string): Promise<Progress> {
const entry = await requireEntry(userId, entryId);
const { watched, total } = await countProgress(
userId,
entry.id,
entry.titleId,
entry.title.type,
entry.currentRun,
);
const [minutes, remaining] = await Promise.all([
prisma.watch.aggregate({
where: { userId, entryId, run: entry.currentRun },
_sum: { minutes: true },
}),
entry.title.type === "SERIES"
? prisma.episode.aggregate({
where: {
titleId: entry.titleId,
seasonNumber: { gt: SPECIALS_SEASON },
watches: { none: { userId, run: entry.currentRun } },
},
_sum: { runtime: true },
})
: Promise.resolve({ _sum: { runtime: null } }),
]);
const minutesLeft =
entry.title.type === "MOVIE"
? watched > 0
? 0
: (entry.title.runtime ?? 0)
: (remaining._sum.runtime ?? 0);
return {
watched,
total,
percent: total > 0 ? Math.round((watched / total) * 100) : 0,
minutesWatched: minutes._sum.minutes ?? 0,
minutesLeft,
};
}
/** Die nächste offene Episode des aktuellen Durchlaufs. */
export async function getNextEpisode(userId: string, entryId: string) {
const entry = await requireEntry(userId, entryId);
if (entry.title.type !== "SERIES") return null;
return 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,
},
});
}
/** Die im aktuellen Durchlauf gesehenen Episoden-IDs — für die Häkchen im UI. */
export async function getWatchedEpisodeIds(userId: string, entryId: string): Promise<Set<string>> {
const entry = await prisma.libraryEntry.findFirst({
where: { id: entryId, userId },
select: { currentRun: true },
});
if (!entry) return new Set();
const watches = await prisma.watch.findMany({
where: { userId, entryId, run: entry.currentRun, episodeId: { not: null } },
select: { episodeId: true },
});
return new Set(watches.map((watch) => watch.episodeId).filter((id): id is string => id !== null));
}
+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 } },
},
});
}