diff --git a/src/lib/labels.ts b/src/lib/labels.ts new file mode 100644 index 0000000..fa4534d --- /dev/null +++ b/src/lib/labels.ts @@ -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 = { + 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(); +} diff --git a/src/lib/library.ts b/src/lib/library.ts new file mode 100644 index 0000000..cb2d31e --- /dev/null +++ b/src/lib/library.ts @@ -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 { + 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 }, +) { + 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 { + 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" }; +} + +/** 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)); +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts new file mode 100644 index 0000000..1ab9dda --- /dev/null +++ b/src/lib/queries.ts @@ -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 = { + 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 { + 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> { + 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(); + + 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; +}; + +/** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */ +export async function getLibraryFacets(userId: string): Promise { + 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; + + 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 } }, + }, + }); +}