CI / pruefen (pull_request) Canceled after 0s
Der Abgleich ordnete nur gegen Titel zu, die Flimmer schon kannte. Auf einer frisch aufgesetzten Instanz meldet Jellyfin damit tausend Eintraege und Flimmer ordnet keinen einzigen zu — der Bericht konnte nur aufzaehlen, was er nicht einsortieren konnte. Aus dem Bericht wird eine Auswahlliste: anhaken, was uebernommen werden soll, und nur das aus TMDB nachladen. Ein pauschales Haekchen waere bei tausend Eintraegen keine Wahl gewesen — in einer Mediathek liegen Konzertmitschnitte neben dem, was man wirklich verfolgt. Der Bericht ist dafuer umgebaut: - Zusammengefasst je TMDB-Kennung. Jellyfin fuehrt oft mehrere Dateien zum selben Film; in der Liste steht er einmal, mit der Zahl der Dateien. - Die Grenze stand bei 50 und war bei ueber tausend gelesenen Eintraegen ein Blick durchs Schluesselloch. Jetzt 1000, und was darueber hinausgeht, wird gezaehlt statt stillschweigend abgeschnitten. - Der Typ kommt aus dem Item statt aus dem Umkehrschluss "keine Episode, also Film". Eine Probe zeigte sonst eine Serie als Film. Nachgeladen wird nur auf Klick, hoechstens hundert je Lauf, und ein einzelner Fehlschlag beendet den Lauf nicht. Aus der Liste verschwindet nur, was tatsaechlich ankam: ein Fehlschlag laesst den Eintrag stehen, damit man es erneut versuchen kann, ohne den ganzen Abgleich zu wiederholen. Mein erster Entwurf strich auch Gescheiterte — der Test hielt das falsche Verhalten fest und wurde mitkorrigiert. Eintraege ohne TMDB-Kennung bleiben sichtbar, aber nicht auswaehlbar: dort fehlt die Kennung in Jellyfin selbst, da kann Flimmer nichts holen. Fuenf Tests dazu. Der Importschritt selbst laesst sich lokal nur ueber die Domaenenschicht pruefen — der Entwicklungs-Stub speist sich aus dem Katalog und kann per Definition nichts liefern, was dort fehlt. Der Klickweg belegt Liste, Entdopplung, Suche, Auswahl, die gesperrten Zeilen und die ehrliche Fehlermeldung. Refs #16
461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
"use server";
|
||
|
||
import { revalidatePath } from "next/cache";
|
||
import { z } from "zod";
|
||
import {
|
||
checkPasswordStrength,
|
||
createApiKey,
|
||
destroyAllSessions,
|
||
getSessionUser,
|
||
hashPassword,
|
||
verifyPassword,
|
||
} from "@/lib/auth";
|
||
import { prisma } from "@/lib/db";
|
||
import { setSetting } from "@/lib/settings";
|
||
import { sendTestNotification } from "@/lib/notify";
|
||
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
|
||
import { runSync } from "@/lib/sync";
|
||
import {
|
||
connectJellyfin,
|
||
deleteConnection,
|
||
importUnassigned,
|
||
syncConnection,
|
||
testConnection,
|
||
updateConnection,
|
||
type SyncReport,
|
||
} from "@/lib/mediaserver";
|
||
|
||
/** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */
|
||
|
||
export type SettingsResult = { ok: boolean; message?: string; secret?: string };
|
||
|
||
async function run(
|
||
fn: (userId: string) => Promise<{ message?: string; secret?: string } | void>,
|
||
): Promise<SettingsResult> {
|
||
const user = await getSessionUser();
|
||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||
|
||
try {
|
||
const result = (await fn(user.id)) ?? {};
|
||
revalidatePath("/einstellungen", "layout");
|
||
revalidatePath("/", "layout");
|
||
return { ok: true, ...result };
|
||
} catch (error) {
|
||
console.error("Einstellungs-Aktion fehlgeschlagen:", error);
|
||
return {
|
||
ok: false,
|
||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||
};
|
||
}
|
||
}
|
||
|
||
async function requireAdminUser() {
|
||
const user = await getSessionUser();
|
||
if (!user || user.role !== "ADMIN") {
|
||
throw new Error("Dafür fehlen dir die Rechte.");
|
||
}
|
||
return user;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Profil
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const profileSchema = z.object({
|
||
name: z.string().trim().min(1).max(80),
|
||
region: z.string().trim().length(2).toUpperCase(),
|
||
publicProfile: z.boolean(),
|
||
profileSlug: z
|
||
.string()
|
||
.trim()
|
||
.toLowerCase()
|
||
.regex(/^[a-z0-9-]{3,40}$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 40 Zeichen.")
|
||
.nullable(),
|
||
notifyNewEpisode: z.boolean(),
|
||
notifyRelease: z.boolean(),
|
||
notifyAvailability: z.boolean(),
|
||
});
|
||
|
||
export async function updateProfileAction(data: unknown) {
|
||
return run(async (userId) => {
|
||
const parsed = profileSchema.parse(data);
|
||
|
||
if (parsed.publicProfile && !parsed.profileSlug) {
|
||
throw new Error("Für ein öffentliches Profil braucht es eine Adresse.");
|
||
}
|
||
|
||
if (parsed.profileSlug) {
|
||
const taken = await prisma.user.findFirst({
|
||
where: { profileSlug: parsed.profileSlug, id: { not: userId } },
|
||
select: { id: true },
|
||
});
|
||
if (taken) throw new Error("Diese Profiladresse ist schon vergeben.");
|
||
}
|
||
|
||
await prisma.user.update({ where: { id: userId }, data: parsed });
|
||
return { message: "Gespeichert" };
|
||
});
|
||
}
|
||
|
||
const passwordSchema = z.object({
|
||
current: z.string().min(1),
|
||
next: z.string().min(1),
|
||
repeat: z.string().min(1),
|
||
});
|
||
|
||
export async function changePasswordAction(data: unknown) {
|
||
return run(async (userId) => {
|
||
const parsed = passwordSchema.parse(data);
|
||
|
||
if (parsed.next !== parsed.repeat) throw new Error("Die neuen Passwörter stimmen nicht überein.");
|
||
const weak = checkPasswordStrength(parsed.next);
|
||
if (weak) throw new Error(weak);
|
||
|
||
const user = await prisma.user.findUniqueOrThrow({
|
||
where: { id: userId },
|
||
select: { passwordHash: true },
|
||
});
|
||
if (!user.passwordHash) {
|
||
throw new Error("Dieses Konto meldet sich über SSO an und hat kein Passwort.");
|
||
}
|
||
if (!(await verifyPassword(parsed.current, user.passwordHash))) {
|
||
throw new Error("Das bisherige Passwort stimmt nicht.");
|
||
}
|
||
|
||
await prisma.user.update({
|
||
where: { id: userId },
|
||
data: { passwordHash: await hashPassword(parsed.next) },
|
||
});
|
||
|
||
// Nach einem Passwortwechsel fliegen alle anderen Geräte raus.
|
||
await destroyAllSessions(userId);
|
||
return { message: "Passwort geändert — bitte neu anmelden." };
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// API-Schlüssel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function createApiKeyAction(name: string) {
|
||
return run(async (userId) => {
|
||
const clean = z.string().trim().min(1).max(60).parse(name);
|
||
const { plain } = await createApiKey(userId, clean);
|
||
// Der Klartext existiert nur hier — danach steht in der Datenbank nur der Hash.
|
||
return { message: "Schlüssel angelegt", secret: plain };
|
||
});
|
||
}
|
||
|
||
export async function revokeApiKeyAction(keyId: string) {
|
||
return run(async (userId) => {
|
||
await prisma.apiKey.updateMany({
|
||
where: { id: keyId, userId },
|
||
data: { revokedAt: new Date() },
|
||
});
|
||
return { message: "Schlüssel zurückgezogen" };
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Benachrichtigungskanäle
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const channelSchema = z.object({
|
||
kind: z.enum(["EMAIL", "NTFY", "GOTIFY", "WEBHOOK"]),
|
||
name: z.string().trim().min(1).max(60),
|
||
config: z.record(z.string(), z.string().max(500)).default({}),
|
||
});
|
||
|
||
export async function saveChannelAction(data: unknown, channelId?: string) {
|
||
return run(async (userId) => {
|
||
const parsed = channelSchema.parse(data);
|
||
|
||
if (channelId) {
|
||
await prisma.notificationChannel.updateMany({
|
||
where: { id: channelId, userId },
|
||
data: { kind: parsed.kind, name: parsed.name, config: parsed.config },
|
||
});
|
||
return { message: "Kanal gespeichert" };
|
||
}
|
||
|
||
await prisma.notificationChannel.create({
|
||
data: { userId, kind: parsed.kind, name: parsed.name, config: parsed.config },
|
||
});
|
||
return { message: "Kanal angelegt" };
|
||
});
|
||
}
|
||
|
||
export async function toggleChannelAction(channelId: string, enabled: boolean) {
|
||
return run(async (userId) => {
|
||
await prisma.notificationChannel.updateMany({
|
||
where: { id: channelId, userId },
|
||
data: { enabled },
|
||
});
|
||
return { message: enabled ? "Kanal aktiv" : "Kanal ruht" };
|
||
});
|
||
}
|
||
|
||
export async function deleteChannelAction(channelId: string) {
|
||
return run(async (userId) => {
|
||
await prisma.notificationChannel.deleteMany({ where: { id: channelId, userId } });
|
||
return { message: "Kanal entfernt" };
|
||
});
|
||
}
|
||
|
||
export async function testChannelAction(channelId: string) {
|
||
return run(async (userId) => {
|
||
await sendTestNotification(userId, channelId);
|
||
return { message: "Testnachricht verschickt" };
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Import
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export type ImportResult = { ok: boolean; message?: string; summary?: ImportSummary };
|
||
|
||
export async function importDataAction(
|
||
format: "csv" | "trakt",
|
||
content: string,
|
||
): Promise<ImportResult> {
|
||
const user = await getSessionUser();
|
||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||
|
||
if (content.length > 8_000_000) {
|
||
return { ok: false, message: "Die Datei ist zu groß (mehr als 8 MB)." };
|
||
}
|
||
|
||
try {
|
||
const summary = format === "csv" ? await importCsv(user.id, content) : await importTrakt(user.id, content);
|
||
revalidatePath("/bibliothek", "layout");
|
||
revalidatePath("/", "layout");
|
||
|
||
return {
|
||
ok: true,
|
||
message: `${summary.added} aufgenommen, ${summary.skipped} übersprungen`,
|
||
summary,
|
||
};
|
||
} catch (error) {
|
||
console.error("Import fehlgeschlagen:", error);
|
||
return { ok: false, message: error instanceof Error ? error.message : "Import fehlgeschlagen." };
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Verwaltung
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function setRegistrationAction(enabled: boolean) {
|
||
return run(async () => {
|
||
await requireAdminUser();
|
||
await setSetting("registrationEnabled", enabled);
|
||
return { message: enabled ? "Registrierung offen" : "Registrierung geschlossen" };
|
||
});
|
||
}
|
||
|
||
export async function setInstanceNameAction(name: string) {
|
||
return run(async () => {
|
||
await requireAdminUser();
|
||
await setSetting("instanceName", z.string().trim().min(1).max(60).parse(name));
|
||
return { message: "Name gespeichert" };
|
||
});
|
||
}
|
||
|
||
export async function setPublicProfilesAction(enabled: boolean) {
|
||
return run(async () => {
|
||
await requireAdminUser();
|
||
await setSetting("publicProfilesEnabled", enabled);
|
||
return { message: enabled ? "Öffentliche Profile erlaubt" : "Öffentliche Profile abgeschaltet" };
|
||
});
|
||
}
|
||
|
||
export async function setUserActiveAction(targetId: string, active: boolean) {
|
||
return run(async (userId) => {
|
||
await requireAdminUser();
|
||
if (targetId === userId) throw new Error("Dich selbst kannst du nicht sperren.");
|
||
|
||
await prisma.user.update({ where: { id: targetId }, data: { isActive: active } });
|
||
if (!active) await destroyAllSessions(targetId);
|
||
|
||
revalidatePath("/verwaltung", "layout");
|
||
return { message: active ? "Konto freigegeben" : "Konto gesperrt" };
|
||
});
|
||
}
|
||
|
||
export async function setUserRoleAction(targetId: string, role: "ADMIN" | "USER") {
|
||
return run(async (userId) => {
|
||
await requireAdminUser();
|
||
if (targetId === userId) throw new Error("Deine eigene Rolle kannst du nicht ändern.");
|
||
|
||
if (role === "USER") {
|
||
const admins = await prisma.user.count({ where: { role: "ADMIN", isActive: true } });
|
||
if (admins <= 1) throw new Error("Es muss mindestens eine Administration geben.");
|
||
}
|
||
|
||
await prisma.user.update({ where: { id: targetId }, data: { role } });
|
||
revalidatePath("/verwaltung", "layout");
|
||
return { message: role === "ADMIN" ? "Zur Administration ernannt" : "Rechte entzogen" };
|
||
});
|
||
}
|
||
|
||
export async function runSyncAction() {
|
||
return run(async () => {
|
||
await requireAdminUser();
|
||
const stats = await runSync({ limit: 40 });
|
||
revalidatePath("/verwaltung", "layout");
|
||
|
||
return {
|
||
message:
|
||
stats.errors.length === 0
|
||
? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen`
|
||
: `Mit ${stats.errors.length} Fehlern beendet: ${stats.errors[0]}`,
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Medienserver
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const verbindungSchema = z.object({
|
||
baseUrl: z.string().trim().min(1).max(300),
|
||
username: z.string().trim().min(1).max(120),
|
||
password: z.string().min(1).max(300),
|
||
});
|
||
|
||
export type MediaServerResult = SettingsResult & {
|
||
pruefung?: {
|
||
serverName: string;
|
||
version: string | null;
|
||
itemCount: number;
|
||
hasUserData: boolean;
|
||
withTmdbId: number;
|
||
};
|
||
bericht?: SyncReport;
|
||
};
|
||
|
||
export async function connectMediaServerAction(input: {
|
||
baseUrl: string;
|
||
username: string;
|
||
password: string;
|
||
}): Promise<MediaServerResult> {
|
||
const user = await getSessionUser();
|
||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||
|
||
const daten = verbindungSchema.safeParse(input);
|
||
if (!daten.success) return { ok: false, message: "Adresse, Name und Passwort werden gebraucht." };
|
||
|
||
try {
|
||
const { pruefung } = await connectJellyfin(user.id, daten.data);
|
||
revalidatePath("/einstellungen", "layout");
|
||
return {
|
||
ok: true,
|
||
message: `Verbunden mit ${pruefung.serverName}`,
|
||
pruefung,
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||
};
|
||
}
|
||
}
|
||
|
||
export async function testMediaServerAction(connectionId: string): Promise<MediaServerResult> {
|
||
const user = await getSessionUser();
|
||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||
|
||
try {
|
||
const pruefung = await testConnection(user.id, connectionId);
|
||
return { ok: true, message: `${pruefung.serverName} antwortet`, pruefung };
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||
};
|
||
}
|
||
}
|
||
|
||
export async function syncMediaServerAction(
|
||
connectionId: string,
|
||
aufnehmen: boolean,
|
||
): Promise<MediaServerResult> {
|
||
const user = await getSessionUser();
|
||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||
|
||
try {
|
||
const bericht = await syncConnection(user.id, connectionId, { aufnehmen });
|
||
revalidatePath("/einstellungen", "layout");
|
||
revalidatePath("/bibliothek", "layout");
|
||
return {
|
||
ok: true,
|
||
message:
|
||
`${bericht.gelesen} Einträge gelesen, ${bericht.bestandGesetzt}× „hab ich“ gesetzt, ` +
|
||
`${bericht.sichtungenAngelegt} Sichtungen übernommen`,
|
||
bericht,
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||
};
|
||
}
|
||
}
|
||
|
||
const auswahlSchema = z
|
||
.array(
|
||
z.object({
|
||
tmdbId: z.number().int().positive(),
|
||
typ: z.enum(["SERIES", "MOVIE"]),
|
||
}),
|
||
)
|
||
.min(1)
|
||
.max(1000);
|
||
|
||
/** Holt die ausgewählten Titel aus TMDB nach. */
|
||
export async function importMediaServerTitlesAction(
|
||
connectionId: string,
|
||
auswahl: unknown,
|
||
): Promise<MediaServerResult & { uebrig?: number }> {
|
||
const user = await getSessionUser();
|
||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||
|
||
const daten = auswahlSchema.safeParse(auswahl);
|
||
if (!daten.success) return { ok: false, message: "Es war nichts ausgewählt." };
|
||
|
||
try {
|
||
const ergebnis = await importUnassigned(user.id, connectionId, daten.data);
|
||
revalidatePath("/einstellungen", "layout");
|
||
revalidatePath("/bibliothek", "layout");
|
||
|
||
const teile = [`${ergebnis.nachgeladen} Titel nachgeladen`];
|
||
if (ergebnis.fehler.length > 0) teile.push(`${ergebnis.fehler.length} fehlgeschlagen`);
|
||
if (ergebnis.uebrig > 0) teile.push(`${ergebnis.uebrig} bleiben für den nächsten Lauf`);
|
||
|
||
return { ok: true, message: teile.join(", "), uebrig: ergebnis.uebrig };
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
|
||
};
|
||
}
|
||
}
|
||
|
||
export async function updateMediaServerAction(
|
||
connectionId: string,
|
||
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
|
||
) {
|
||
return run(async (userId) => {
|
||
await updateConnection(userId, connectionId, data);
|
||
return { message: "Gespeichert" };
|
||
});
|
||
}
|
||
|
||
export async function deleteMediaServerAction(connectionId: string) {
|
||
return run(async (userId) => {
|
||
await deleteConnection(userId, connectionId);
|
||
return { message: "Verbindung entfernt" };
|
||
});
|
||
}
|