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.
288 lines
9.5 KiB
TypeScript
288 lines
9.5 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { z } from "zod";
|
|
import { getSessionUser } from "@/lib/auth";
|
|
import {
|
|
addToLibrary,
|
|
LibraryError,
|
|
markEpisodesWatched,
|
|
markMovieWatched,
|
|
markSeasonWatched,
|
|
markUpTo,
|
|
moveToTrash,
|
|
purgeEntry,
|
|
restoreFromTrash,
|
|
setStatus,
|
|
startRewatch,
|
|
undoWatches,
|
|
unmarkEpisodes,
|
|
unmarkSeason,
|
|
updateEntryDetails,
|
|
watchNextEpisode,
|
|
type WatchResult,
|
|
} from "@/lib/library";
|
|
import { prisma } from "@/lib/db";
|
|
|
|
/**
|
|
* Server-Aktionen rund um die Bibliothek.
|
|
*
|
|
* Jede Aktion holt sich die Sitzung selbst — auf Angaben aus dem Formular ist
|
|
* beim Zugriffsschutz kein Verlass. Zurück kommt immer dieselbe Hülle, damit
|
|
* das UI Erfolg, Fehler und Rückgängig-Möglichkeit einheitlich behandeln kann.
|
|
*/
|
|
|
|
export type ActionResult = {
|
|
ok: boolean;
|
|
message?: string;
|
|
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
|
|
watchIds?: string[];
|
|
};
|
|
|
|
async function run(
|
|
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
|
|
revalidate: string[] = [],
|
|
): Promise<ActionResult> {
|
|
const user = await getSessionUser();
|
|
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
|
|
|
try {
|
|
const result = (await fn(user.id)) ?? {};
|
|
for (const path of revalidate) revalidatePath(path, "layout");
|
|
return { ok: true, message: result.message, watchIds: result.watchIds };
|
|
} catch (error) {
|
|
if (error instanceof LibraryError) return { ok: false, message: error.message };
|
|
console.error("Bibliotheksaktion fehlgeschlagen:", error);
|
|
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
|
|
}
|
|
}
|
|
|
|
const PATHS = ["/", "/bibliothek", "/titel", "/papierkorb", "/kalender", "/statistik"];
|
|
|
|
const cuid = z.string().min(1).max(64);
|
|
const statusSchema = z.enum(["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Aufnehmen und entfernen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function addTitleAction(titleId: string, status: string = "WATCHLIST") {
|
|
return run(async (userId) => {
|
|
const parsedStatus = statusSchema.parse(status);
|
|
await addToLibrary(userId, cuid.parse(titleId), parsedStatus);
|
|
return {
|
|
message:
|
|
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
|
|
};
|
|
}, PATHS);
|
|
}
|
|
|
|
export async function trashEntryAction(entryId: string) {
|
|
return run(async (userId) => {
|
|
await moveToTrash(userId, cuid.parse(entryId));
|
|
return { message: "In den Papierkorb gelegt" };
|
|
}, PATHS);
|
|
}
|
|
|
|
export async function restoreEntryAction(entryId: string) {
|
|
return run(async (userId) => {
|
|
await restoreFromTrash(userId, cuid.parse(entryId));
|
|
return { message: "Wiederhergestellt" };
|
|
}, PATHS);
|
|
}
|
|
|
|
export async function purgeEntryAction(entryId: string) {
|
|
return run(async (userId) => {
|
|
await purgeEntry(userId, cuid.parse(entryId));
|
|
return { message: "Endgültig gelöscht" };
|
|
}, PATHS);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Status und Angaben
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function setStatusAction(entryId: string, status: string) {
|
|
return run(async (userId) => {
|
|
await setStatus(userId, cuid.parse(entryId), statusSchema.parse(status));
|
|
return { message: "Status geändert" };
|
|
}, PATHS);
|
|
}
|
|
|
|
const detailsSchema = z.object({
|
|
rating: z.number().int().min(1).max(10).nullable().optional(),
|
|
notes: z.string().max(5000).nullable().optional(),
|
|
whereToWatch: z.string().max(120).nullable().optional(),
|
|
favorite: z.boolean().optional(),
|
|
owned: z.boolean().optional(),
|
|
ownedSource: z.string().max(80).nullable().optional(),
|
|
});
|
|
|
|
export async function updateEntryAction(entryId: string, data: unknown) {
|
|
return run(async (userId) => {
|
|
await updateEntryDetails(userId, cuid.parse(entryId), detailsSchema.parse(data));
|
|
return { message: "Gespeichert" };
|
|
}, PATHS);
|
|
}
|
|
|
|
export async function startRewatchAction(entryId: string) {
|
|
return run(async (userId) => {
|
|
const entry = await startRewatch(userId, cuid.parse(entryId));
|
|
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` };
|
|
}, PATHS);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sichtungen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function toResult(result: WatchResult) {
|
|
return { message: result.label, watchIds: result.watchIds };
|
|
}
|
|
|
|
export async function toggleEpisodeAction(entryId: string, episodeId: string, watched: boolean) {
|
|
return run(async (userId) => {
|
|
if (watched) {
|
|
return toResult(await markEpisodesWatched(userId, cuid.parse(entryId), [cuid.parse(episodeId)]));
|
|
}
|
|
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
|
|
return { message: "Markierung entfernt" };
|
|
}, PATHS);
|
|
}
|
|
|
|
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
|
|
return run(async (userId) => {
|
|
if (watched) {
|
|
return toResult(await markSeasonWatched(userId, cuid.parse(entryId), cuid.parse(seasonId)));
|
|
}
|
|
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
|
|
return { message: "Staffel zurückgesetzt" };
|
|
}, PATHS);
|
|
}
|
|
|
|
export async function markUpToAction(entryId: string, episodeId: string) {
|
|
return run(
|
|
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
|
|
PATHS,
|
|
);
|
|
}
|
|
|
|
export async function watchNextAction(entryId: string) {
|
|
return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS);
|
|
}
|
|
|
|
export async function markMovieAction(entryId: string) {
|
|
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
|
|
}
|
|
|
|
export async function undoAction(entryId: string, watchIds: string[]) {
|
|
return run(async (userId) => {
|
|
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
|
|
return { message: "Rückgängig gemacht" };
|
|
}, PATHS);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tags
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function setTagsAction(entryId: string, tagNames: string[]) {
|
|
return run(async (userId) => {
|
|
const id = cuid.parse(entryId);
|
|
const names = z
|
|
.array(z.string().trim().min(1).max(40))
|
|
.max(20)
|
|
.parse(tagNames)
|
|
.map((name) => name.toLocaleLowerCase("de-DE"));
|
|
|
|
const entry = await prisma.libraryEntry.findFirst({
|
|
where: { id, userId },
|
|
select: { id: true },
|
|
});
|
|
if (!entry) throw new LibraryError("Dieser Eintrag gehört nicht zu deiner Bibliothek.");
|
|
|
|
const tags = await Promise.all(
|
|
names.map((name) =>
|
|
prisma.tag.upsert({
|
|
where: { userId_name: { userId, name } },
|
|
create: { userId, name },
|
|
update: {},
|
|
select: { id: true },
|
|
}),
|
|
),
|
|
);
|
|
|
|
await prisma.$transaction([
|
|
prisma.tagOnEntry.deleteMany({
|
|
where: { entryId: id, tagId: { notIn: tags.map((tag) => tag.id) } },
|
|
}),
|
|
...tags.map((tag) =>
|
|
prisma.tagOnEntry.upsert({
|
|
where: { tagId_entryId: { tagId: tag.id, entryId: id } },
|
|
create: { tagId: tag.id, entryId: id },
|
|
update: {},
|
|
}),
|
|
),
|
|
]);
|
|
|
|
return { message: "Tags gespeichert" };
|
|
}, PATHS);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Manuell angelegte Titel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const manualSchema = z.object({
|
|
type: z.enum(["SERIES", "MOVIE"]),
|
|
title: z.string().trim().min(1).max(200),
|
|
year: z.number().int().min(1880).max(2200).nullable().optional(),
|
|
runtime: z.number().int().min(1).max(2000).nullable().optional(),
|
|
overview: z.string().trim().max(4000).optional(),
|
|
genres: z.array(z.string().trim().min(1).max(40)).max(10).optional(),
|
|
status: statusSchema.default("WATCHLIST"),
|
|
});
|
|
|
|
/**
|
|
* Legt einen Titel ohne TMDB-Treffer an.
|
|
*
|
|
* Für alles, was TMDB nicht kennt: Heimvideos, Vorträge, regionale
|
|
* Produktionen. Solche Titel haben keine `tmdbId` und werden vom Sync-Lauf
|
|
* daher nie angefasst.
|
|
*/
|
|
export async function createManualTitleAction(data: unknown): Promise<ActionResult & { titleId?: string }> {
|
|
const user = await getSessionUser();
|
|
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
|
|
|
const parsed = manualSchema.safeParse(data);
|
|
if (!parsed.success) {
|
|
return { ok: false, message: "Bitte gib mindestens einen Titel an." };
|
|
}
|
|
|
|
const { type, title, year, runtime, overview, genres, status } = parsed.data;
|
|
|
|
try {
|
|
const created = await prisma.title.create({
|
|
data: {
|
|
type,
|
|
title,
|
|
overview: overview || null,
|
|
runtime: runtime ?? null,
|
|
releaseDate: year ? new Date(Date.UTC(year, 0, 1)) : null,
|
|
genres: genres ?? [],
|
|
status: "Manuell angelegt",
|
|
syncedAt: new Date(),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
await addToLibrary(user.id, created.id, status);
|
|
for (const path of PATHS) revalidatePath(path, "layout");
|
|
|
|
return { ok: true, message: `„${title}" angelegt`, titleId: created.id };
|
|
} catch (error) {
|
|
console.error("Manuellen Titel anlegen fehlgeschlagen:", error);
|
|
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
|
|
}
|
|
}
|