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.
238 lines
6.9 KiB
TypeScript
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 } },
|
|
},
|
|
});
|
|
}
|