Files
flimmer/src/lib/queries.ts
T
Florian Wilde 9ee8664233
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s
feat: eigene Sammlung — „hab ich" mit Quelle
Streaming-Verfügbarkeit beantwortet nur die halbe Frage: manche Filme laufen
nirgends mehr, die hat man gekauft und liegen zu Hause — in Jellyfin, Plex,
Emby, auf dem NAS oder als Scheibe.

Bewusst getrennt vom Feld „wo schaue ich das": das eine sagt, wo ein Titel
gerade läuft, und das ändert sich ständig; das andere sagt, was einem gehört,
und das bleibt. Auf der Detailseite steht die Sammlung deshalb vor der
Verfügbarkeit — was man hat, ist die verlässlichere Antwort.

Durchgezogen bis in Filter, Export, CSV-Import und REST-API.
2026-09-01 21:15:53 +02:00

238 lines
6.9 KiB
TypeScript

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;
/** Nur Titel, die in der eigenen Sammlung liegen. */
owned?: boolean;
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;
owned: boolean;
ownedSource: 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.owned ? { owned: true } : {}),
...(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,
owned: entry.owned,
ownedSource: entry.ownedSource,
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>;
/** Wie viele Titel in der eigenen Sammlung liegen. */
owned: 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, owned] = 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 } }),
prisma.libraryEntry.count({ where: { userId, deletedAt: null, owned: true } }),
]);
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,
owned,
};
}
/** 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 } },
},
});
}