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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user