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 { 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 { 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; owned?: boolean; ownedSource?: string | null; }, ) { 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 } : {}), ...(data.owned !== undefined ? { owned: data.owned } : {}), ...(data.ownedSource !== undefined ? { ownedSource: data.ownedSource?.trim() || null } : {}), }, }); } 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 { 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 { 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 { 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" }; } /** * Nimmt die Sichtung eines Films im aktuellen Durchlauf zurück. * * Das Gegenstück zu `markMovieWatched()` — und das Pendant zu * `unmarkEpisodes()` bei Serien. * * Die Eingrenzung auf `run` ist wesentlich: wer einen Film dreimal gesehen * hat, hat drei Sichtungen. Ohne `run` löschte das Zurücknehmen alle drei und * verfälschte rückwirkend die Statistik. Zurückgenommen wird nur, was im * laufenden Durchlauf abgehakt wurde. */ export async function unmarkMovie(userId: string, entryId: string) { const entry = await requireEntry(userId, entryId); if (entry.title.type !== "MOVIE") throw new LibraryError("Das ist keine Filmsichtung."); await prisma.watch.deleteMany({ where: { userId, entryId, episodeId: null, run: entry.currentRun }, }); return reconcileStatus(userId, entryId); } /** 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 { 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 { 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> { 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)); }