CI / pruefen (pull_request) Canceled after 0s
War ein Film einmal als gesehen markiert, gab es keinen Weg zurueck: im
Zustand "gesehen" stand statt eines Knopfes ein <p> — ein Etikett ohne
Klickziel. Der Statuswechsel konnte das nicht heilen, weil "gesehen" aus den
Sichtungen abgeleitet wird und setStatus() die absichtlich nicht anfasst.
Und eine Gegenfunktion fuer Filme gab es nicht: unmarkEpisodes() und
unmarkSeason() waren da, ein unmarkMovie() nie geschrieben.
unmarkMovie() ist auf run: entry.currentRun und episodeId: null eingegrenzt.
Wer einen Film dreimal gesehen hat, hat drei Sichtungen — ohne die
Eingrenzung loeschte das Zuruecknehmen alle drei und verfaelschte
rueckwirkend die Statistik.
Die gruene Plakette bleibt stehen, ist jetzt aber klickbar, so wie sich
Episodenhaekchen auch abwaehlen lassen. Ueber die Schnittstelle geht es mit
DELETE {"action":"movie"} als Gegenstueck zum gleichnamigen POST, ohne dass
man die watchIds kennen muss.
Drei Tests: Zuruecknehmen samt erneutem Abhaken, die Eingrenzung auf den
laufenden Durchlauf, und dass Serien abgewiesen werden. Klickweg und
Endpunkt gegen einen Produktionsbau geprueft.
Der im Issue vermutete Nebenpunkt trifft nicht zu: report() blendet das
Zuruecknehmen nur bei ids.length > 0 ein, ein wirkungsloses Angebot gab es
also nie.
Refs #5
542 lines
17 KiB
TypeScript
542 lines
17 KiB
TypeScript
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;
|
|
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<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" };
|
|
}
|
|
|
|
/**
|
|
* 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<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));
|
|
}
|