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