Merge #18: Unbekannte Jellyfin-Titel zur Auswahl stellen und nachladen
CI / pruefen (push) Canceled after 0s
CI / pruefen (push) Canceled after 0s
Closes #16
This commit was merged in pull request #18.
This commit is contained in:
@@ -410,6 +410,38 @@ Beim Abgleich passiert dreierlei:
|
|||||||
Danach läuft der Abgleich im täglichen Hintergrundlauf mit — dort allerdings
|
Danach läuft der Abgleich im täglichen Hintergrundlauf mit — dort allerdings
|
||||||
ohne automatisches Aufnehmen.
|
ohne automatisches Aufnehmen.
|
||||||
|
|
||||||
|
### Titel, die Flimmer noch nicht kennt
|
||||||
|
|
||||||
|
Beim ersten Abgleich einer gewachsenen Mediathek passiert oft etwas
|
||||||
|
Ernüchterndes: Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen
|
||||||
|
einzigen** zu. Das ist richtig so — Flimmer kennt nur, was du selbst
|
||||||
|
aufgenommen oder über die Suche importiert hast.
|
||||||
|
|
||||||
|
Deshalb steht unter dem Bericht eine **Auswahlliste**: alles, was auf dem
|
||||||
|
Server liegt und Flimmer unbekannt ist. Hake an, was übernommen werden soll,
|
||||||
|
und klicke auf Übernehmen — die Daten holt Flimmer dann von TMDB, bei Serien
|
||||||
|
samt Staffeln und Episoden.
|
||||||
|
|
||||||
|
Zwei Dinge, die dabei helfen:
|
||||||
|
|
||||||
|
- **Zusammengefasst.** Führt Jellyfin drei Dateien zum selben Film (Extended
|
||||||
|
Edition, zweite Fassung), steht er trotzdem nur einmal in der Liste — mit
|
||||||
|
dem Vermerk, wie viele Dateien darauf zeigen.
|
||||||
|
- **Es werden hundert auf einmal geholt.** Wer mehr auswählt, bekommt den Rest
|
||||||
|
beim nächsten Klick. Jeder Titel bedeutet einen Abruf bei TMDB, bei Serien
|
||||||
|
einen je Staffel.
|
||||||
|
|
||||||
|
Manche Einträge lassen sich **nicht** anhaken: dort fehlt die TMDB-Kennung in
|
||||||
|
Jellyfin selbst. Das trifft oft Extended Editions, Konzertmitschnitte oder
|
||||||
|
Dateien mit eigenwilliger Benennung. Meist hilft es, in Jellyfin die Erkennung
|
||||||
|
für diesen Titel nachzuziehen — danach taucht er beim nächsten Abgleich mit
|
||||||
|
Kennung auf.
|
||||||
|
|
||||||
|
> **Der Unterschied zwischen den beiden Häkchen**
|
||||||
|
> „Bekannte Titel dabei in die Bibliothek aufnehmen" beim Abgleich nimmt auf,
|
||||||
|
> was Flimmer **kennt**, aber noch nicht in deiner Bibliothek steht.
|
||||||
|
> Die Auswahlliste holt, was Flimmer **nicht kennt**.
|
||||||
|
|
||||||
**Der Abgleich ist eine Einbahnstraße.** Er ergänzt, was auf dem Server steht,
|
**Der Abgleich ist eine Einbahnstraße.** Er ergänzt, was auf dem Server steht,
|
||||||
und löscht nie etwas, das in Flimmer steht. Markierst du einen Film in Flimmer
|
und löscht nie etwas, das in Flimmer steht. Markierst du einen Film in Flimmer
|
||||||
als gesehen und löschst ihn in Jellyfin, bleibt deine Sichtung erhalten.
|
als gesehen und löschst ihn in Jellyfin, bleibt deine Sichtung erhalten.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { runSync } from "@/lib/sync";
|
|||||||
import {
|
import {
|
||||||
connectJellyfin,
|
connectJellyfin,
|
||||||
deleteConnection,
|
deleteConnection,
|
||||||
|
importUnassigned,
|
||||||
syncConnection,
|
syncConnection,
|
||||||
testConnection,
|
testConnection,
|
||||||
updateConnection,
|
updateConnection,
|
||||||
@@ -403,6 +404,45 @@ export async function syncMediaServerAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
export async function updateMediaServerAction(
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
|
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ export default async function SettingsPage() {
|
|||||||
syncOwned: verbindung.syncOwned,
|
syncOwned: verbindung.syncOwned,
|
||||||
syncWatched: verbindung.syncWatched,
|
syncWatched: verbindung.syncWatched,
|
||||||
lastSyncAt: verbindung.lastSyncAt?.toISOString() ?? null,
|
lastSyncAt: verbindung.lastSyncAt?.toISOString() ?? null,
|
||||||
|
// Der letzte Bericht liegt als Json in der Datenbank; daraus
|
||||||
|
// speist sich die Auswahlliste, damit sie einen Seitenwechsel
|
||||||
|
// übersteht.
|
||||||
|
offen: (verbindung.lastSyncReport as { nichtZugeordnet?: unknown[] } | null)
|
||||||
|
?.nichtZugeordnet as never ?? [],
|
||||||
|
weitereOffen:
|
||||||
|
((verbindung.lastSyncReport as { weitereNichtZugeordnet?: number } | null)
|
||||||
|
?.weitereNichtZugeordnet ?? 0),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
|
import { clsx } from "clsx";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
connectMediaServerAction,
|
connectMediaServerAction,
|
||||||
deleteMediaServerAction,
|
deleteMediaServerAction,
|
||||||
|
importMediaServerTitlesAction,
|
||||||
syncMediaServerAction,
|
syncMediaServerAction,
|
||||||
testMediaServerAction,
|
testMediaServerAction,
|
||||||
updateMediaServerAction,
|
updateMediaServerAction,
|
||||||
@@ -26,6 +28,14 @@ import { formatDate } from "@/lib/labels";
|
|||||||
* Bibliothek werden.
|
* Bibliothek werden.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type UnassignedItem = {
|
||||||
|
tmdbId: number | null;
|
||||||
|
typ: "SERIES" | "MOVIE" | null;
|
||||||
|
name: string;
|
||||||
|
grund: string;
|
||||||
|
dateien: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type Verbindung = {
|
export type Verbindung = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,6 +45,9 @@ export type Verbindung = {
|
|||||||
syncOwned: boolean;
|
syncOwned: boolean;
|
||||||
syncWatched: boolean;
|
syncWatched: boolean;
|
||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
|
/** Aus dem letzten Abgleich — Grundlage der Auswahlliste. */
|
||||||
|
offen: UnassignedItem[];
|
||||||
|
weitereOffen: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Pruefung = NonNullable<MediaServerResult["pruefung"]>;
|
type Pruefung = NonNullable<MediaServerResult["pruefung"]>;
|
||||||
@@ -171,7 +184,7 @@ export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung
|
|||||||
Verbindung prüfen
|
Verbindung prüfen
|
||||||
</button>
|
</button>
|
||||||
<Haken
|
<Haken
|
||||||
label="Fehlende Titel dabei aufnehmen"
|
label="Bekannte Titel dabei in die Bibliothek aufnehmen"
|
||||||
checked={aufnehmen}
|
checked={aufnehmen}
|
||||||
onChange={setAufnehmen}
|
onChange={setAufnehmen}
|
||||||
/>
|
/>
|
||||||
@@ -184,6 +197,26 @@ export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung
|
|||||||
{pruefung && <Pruefbericht pruefung={pruefung} />}
|
{pruefung && <Pruefbericht pruefung={pruefung} />}
|
||||||
{bericht && <Abgleichbericht bericht={bericht} />}
|
{bericht && <Abgleichbericht bericht={bericht} />}
|
||||||
|
|
||||||
|
{verbindungen.map((verbindung) =>
|
||||||
|
verbindung.offen.length > 0 ? (
|
||||||
|
<Nachladeliste
|
||||||
|
key={`offen-${verbindung.id}`}
|
||||||
|
verbindung={verbindung}
|
||||||
|
laeuft={laeuft || pending}
|
||||||
|
onImport={async (auswahl) => {
|
||||||
|
setLaeuft(true);
|
||||||
|
try {
|
||||||
|
const ergebnis = await importMediaServerTitlesAction(verbindung.id, auswahl);
|
||||||
|
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
|
||||||
{verbindungen.length === 0 && (
|
{verbindungen.length === 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-[var(--fl-muted)]">
|
<p className="text-sm text-[var(--fl-muted)]">
|
||||||
@@ -324,3 +357,144 @@ function Haken({
|
|||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auswahlliste für Titel, die Flimmer noch nicht kennt.
|
||||||
|
*
|
||||||
|
* Der Unterschied, der in der Oberfläche sichtbar sein muss: Das Häkchen oben
|
||||||
|
* nimmt auf, was Flimmer **kennt**. Diese Liste holt, was es **nicht kennt** —
|
||||||
|
* und zwar nur das, was hier angehakt wird. Bei über tausend Einträgen in
|
||||||
|
* einer Mediathek wäre „alles oder nichts" keine Wahl.
|
||||||
|
*/
|
||||||
|
function Nachladeliste({
|
||||||
|
verbindung,
|
||||||
|
laeuft,
|
||||||
|
onImport,
|
||||||
|
}: {
|
||||||
|
verbindung: Verbindung;
|
||||||
|
laeuft: boolean;
|
||||||
|
onImport: (auswahl: { tmdbId: number; typ: "SERIES" | "MOVIE" }[]) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [suche, setSuche] = useState("");
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const gefiltert = verbindung.offen.filter((eintrag) =>
|
||||||
|
eintrag.name.toLocaleLowerCase("de-DE").includes(suche.trim().toLocaleLowerCase("de-DE")),
|
||||||
|
);
|
||||||
|
// Ohne TMDB-Kennung lässt sich nichts holen — die Zeilen bleiben sichtbar,
|
||||||
|
// aber nicht anklickbar. Sie zu verstecken würde die Frage aufwerfen, wo
|
||||||
|
// sie geblieben sind.
|
||||||
|
const holbar = gefiltert.filter((eintrag) => eintrag.tmdbId != null);
|
||||||
|
const ohneKennung = verbindung.offen.length - verbindung.offen.filter((e) => e.tmdbId != null).length;
|
||||||
|
|
||||||
|
function umschalten(tmdbId: number) {
|
||||||
|
setGewaehlt((vorher) => {
|
||||||
|
const neu = new Set(vorher);
|
||||||
|
if (neu.has(tmdbId)) neu.delete(tmdbId);
|
||||||
|
else neu.add(tmdbId);
|
||||||
|
return neu;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const alleGewaehlt = holbar.length > 0 && holbar.every((e) => gewaehlt.has(e.tmdbId!));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--fl-border)] p-4">
|
||||||
|
<h3 className="font-medium">Noch nicht in Flimmer</h3>
|
||||||
|
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||||
|
{verbindung.offen.length} Titel liegen auf {verbindung.name}, sind Flimmer aber unbekannt.
|
||||||
|
Wähle aus, was übernommen werden soll — die Daten kommen dann von TMDB.
|
||||||
|
{verbindung.weitereOffen > 0 && ` (${verbindung.weitereOffen} weitere nicht aufgeführt)`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{ohneKennung > 0 && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||||
|
Bei {ohneKennung} davon fehlt die TMDB-Kennung in Jellyfin selbst — die lassen sich nicht
|
||||||
|
automatisch holen. Meist hilft es, die Erkennung in Jellyfin nachzuziehen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={suche}
|
||||||
|
onChange={(event) => setSuche(event.target.value)}
|
||||||
|
placeholder="In der Liste suchen …"
|
||||||
|
aria-label="Liste durchsuchen"
|
||||||
|
className="fl-input w-56 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setGewaehlt(alleGewaehlt ? new Set() : new Set(holbar.map((e) => e.tmdbId!)))
|
||||||
|
}
|
||||||
|
disabled={holbar.length === 0}
|
||||||
|
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
|
||||||
|
>
|
||||||
|
{alleGewaehlt ? "Auswahl aufheben" : `Alle ${holbar.length} auswählen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto pr-1">
|
||||||
|
{gefiltert.map((eintrag) => {
|
||||||
|
const holbarEintrag = eintrag.tmdbId != null;
|
||||||
|
return (
|
||||||
|
<li key={eintrag.tmdbId ?? `name-${eintrag.name}`}>
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
"flex items-start gap-2 rounded-lg px-2 py-1.5 text-sm",
|
||||||
|
holbarEintrag ? "hover:bg-[var(--fl-surface-2)]" : "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={!holbarEintrag}
|
||||||
|
checked={holbarEintrag && gewaehlt.has(eintrag.tmdbId!)}
|
||||||
|
onChange={() => holbarEintrag && umschalten(eintrag.tmdbId!)}
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 rounded border-[var(--fl-border-strong)] accent-[var(--fl-accent)]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate">{eintrag.name}</span>
|
||||||
|
<span className="block text-xs text-[var(--fl-faint)]">
|
||||||
|
{holbarEintrag
|
||||||
|
? `${eintrag.typ === "SERIES" ? "Serie" : "Film"} · TMDB ${eintrag.tmdbId}`
|
||||||
|
: "ohne TMDB-Kennung"}
|
||||||
|
{eintrag.dateien > 1 && ` · ${eintrag.dateien} Dateien`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{gefiltert.length === 0 && (
|
||||||
|
<li className="px-2 py-1.5 text-sm text-[var(--fl-faint)]">Nichts gefunden.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={laeuft || gewaehlt.size === 0}
|
||||||
|
onClick={() =>
|
||||||
|
onImport(
|
||||||
|
verbindung.offen
|
||||||
|
.filter((eintrag) => eintrag.tmdbId != null && gewaehlt.has(eintrag.tmdbId))
|
||||||
|
.map((eintrag) => ({ tmdbId: eintrag.tmdbId!, typ: eintrag.typ ?? "MOVIE" })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="fl-btn fl-btn-primary mt-3"
|
||||||
|
>
|
||||||
|
{laeuft
|
||||||
|
? "Läuft …"
|
||||||
|
: gewaehlt.size === 0
|
||||||
|
? "Nichts ausgewählt"
|
||||||
|
: `${gewaehlt.size} ${gewaehlt.size === 1 ? "Titel" : "Titel"} übernehmen`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{gewaehlt.size > 100 && (
|
||||||
|
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||||
|
Es werden 100 auf einmal geholt; der Rest bleibt ausgewählt für den nächsten Lauf.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+195
-12
@@ -1,6 +1,8 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { ensureTitle } from "./catalog";
|
||||||
import { decryptSecret, encryptSecret } from "./secrets";
|
import { decryptSecret, encryptSecret } from "./secrets";
|
||||||
import {
|
import {
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -33,12 +35,33 @@ import {
|
|||||||
|
|
||||||
export class MediaServerError extends Error {}
|
export class MediaServerError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Titel, den der Abgleich nicht zuordnen konnte.
|
||||||
|
*
|
||||||
|
* Zusammengefasst je TMDB-Kennung: Jellyfin führt oft mehrere Dateien zum
|
||||||
|
* selben Film (Extended Edition, zweite Fassung), und in einer Auswahlliste
|
||||||
|
* will niemand denselben Titel dreimal anhaken.
|
||||||
|
*/
|
||||||
|
export type UnassignedItem = {
|
||||||
|
/** Fehlt sie, lässt sich der Titel nicht automatisch holen. */
|
||||||
|
tmdbId: number | null;
|
||||||
|
typ: "SERIES" | "MOVIE" | null;
|
||||||
|
name: string;
|
||||||
|
grund: string;
|
||||||
|
/** Wie viele Jellyfin-Dateien auf diesen Titel zeigen. */
|
||||||
|
dateien: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type SyncReport = {
|
export type SyncReport = {
|
||||||
gelesen: number;
|
gelesen: number;
|
||||||
bestandGesetzt: number;
|
bestandGesetzt: number;
|
||||||
sichtungenAngelegt: number;
|
sichtungenAngelegt: number;
|
||||||
aufgenommen: number;
|
aufgenommen: number;
|
||||||
nichtZugeordnet: { name: string; grund: string }[];
|
/** Aus TMDB nachgeladen — nur über die Auswahl, nie von selbst. */
|
||||||
|
nachgeladen: number;
|
||||||
|
nichtZugeordnet: UnassignedItem[];
|
||||||
|
/** Wurde die Liste gekürzt? Dann steht hier, wie viele fehlen. */
|
||||||
|
weitereNichtZugeordnet: number;
|
||||||
fehler: string[];
|
fehler: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,12 +70,25 @@ const LEERER_BERICHT = (): SyncReport => ({
|
|||||||
bestandGesetzt: 0,
|
bestandGesetzt: 0,
|
||||||
sichtungenAngelegt: 0,
|
sichtungenAngelegt: 0,
|
||||||
aufgenommen: 0,
|
aufgenommen: 0,
|
||||||
|
nachgeladen: 0,
|
||||||
nichtZugeordnet: [],
|
nichtZugeordnet: [],
|
||||||
|
weitereNichtZugeordnet: 0,
|
||||||
fehler: [],
|
fehler: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Wie viele unzuordenbare Titel im Bericht landen — der Rest wäre nur Lärm. */
|
/**
|
||||||
const BERICHT_GRENZE = 50;
|
* Obergrenze der Auswahlliste.
|
||||||
|
*
|
||||||
|
* Früher standen hier 50 — gedacht als Schutz vor Lärm, tatsächlich ein Blick
|
||||||
|
* durchs Schlüsselloch: bei über tausend gelesenen Einträgen sah man den
|
||||||
|
* Bruchteil. Jetzt großzügig, aber nicht unbegrenzt: ein Server, der
|
||||||
|
* Unsinn liefert, soll den Bericht nicht ins Unermessliche treiben. Was
|
||||||
|
* darüber hinausgeht, wird gezählt und benannt.
|
||||||
|
*/
|
||||||
|
const BERICHT_GRENZE = 1000;
|
||||||
|
|
||||||
|
/** Wie viele Titel ein Importlauf höchstens holt. */
|
||||||
|
const IMPORT_GRENZE = 100;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Verbindung einrichten
|
// Verbindung einrichten
|
||||||
@@ -209,6 +245,8 @@ export async function syncConnection(
|
|||||||
});
|
});
|
||||||
const eintragNachTitel = new Map(eintraege.map((eintrag) => [eintrag.titleId, eintrag]));
|
const eintragNachTitel = new Map(eintraege.map((eintrag) => [eintrag.titleId, eintrag]));
|
||||||
|
|
||||||
|
// Zusammengefasst je Titel, nicht je Datei.
|
||||||
|
const gesammelt = new Map<string, UnassignedItem>();
|
||||||
const bestandTitelIds = new Set<string>();
|
const bestandTitelIds = new Set<string>();
|
||||||
const gesehen: { titleId: string; episodeNumber: number | null; seasonNumber: number | null; wann: Date; externalId: string }[] = [];
|
const gesehen: { titleId: string; episodeNumber: number | null; seasonNumber: number | null; wann: Date; externalId: string }[] = [];
|
||||||
|
|
||||||
@@ -217,13 +255,13 @@ export async function syncConnection(
|
|||||||
const tmdbId = istEpisode ? tmdbIdOfEpisode(item, serien) : tmdbIdOf(item);
|
const tmdbId = istEpisode ? tmdbIdOfEpisode(item, serien) : tmdbIdOf(item);
|
||||||
|
|
||||||
if (tmdbId == null) {
|
if (tmdbId == null) {
|
||||||
merkeUnzugeordnet(bericht, item, "keine TMDB-Kennung am Item");
|
merkeUnzugeordnet(bericht, gesammelt, item, null, "keine TMDB-Kennung am Item");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const treffer = titelNachTmdb.get(tmdbId);
|
const treffer = titelNachTmdb.get(tmdbId);
|
||||||
if (!treffer) {
|
if (!treffer) {
|
||||||
merkeUnzugeordnet(bericht, item, `TMDB ${tmdbId} nicht im Katalog`);
|
merkeUnzugeordnet(bericht, gesammelt, item, tmdbId, "noch nicht im Katalog");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +316,11 @@ export async function syncConnection(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Namen zuerst, damit die Auswahlliste durchsuchbar wirkt statt zufällig.
|
||||||
|
bericht.nichtZugeordnet = [...gesammelt.values()].sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, "de-DE"),
|
||||||
|
);
|
||||||
|
|
||||||
await prisma.mediaServerConnection.update({
|
await prisma.mediaServerConnection.update({
|
||||||
where: { id: connectionId },
|
where: { id: connectionId },
|
||||||
data: { lastSyncAt: new Date(), lastSyncReport: bericht as object },
|
data: { lastSyncAt: new Date(), lastSyncReport: bericht as object },
|
||||||
@@ -314,13 +357,49 @@ function tmdbIdOfEpisode(item: JellyfinItem, serien: Map<string, JellyfinItem>):
|
|||||||
return serie ? tmdbIdOf(serie) : null;
|
return serie ? tmdbIdOf(serie) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function merkeUnzugeordnet(bericht: SyncReport, item: JellyfinItem, grund: string) {
|
/**
|
||||||
if (bericht.nichtZugeordnet.length >= BERICHT_GRENZE) return;
|
* Merkt sich einen nicht zuzuordnenden Titel — zusammengefasst.
|
||||||
const name =
|
*
|
||||||
item.Type === "Episode" && item.SeriesName
|
* Der Schlüssel ist die TMDB-Kennung, sonst der Name. Jellyfin führt oft
|
||||||
? `${item.SeriesName} — ${item.Name}`
|
* mehrere Dateien zum selben Film; in der Auswahlliste soll er einmal stehen,
|
||||||
: (item.Name ?? "Ohne Titel");
|
* mit dem Vermerk, wie viele Dateien darauf zeigen.
|
||||||
bericht.nichtZugeordnet.push({ name, grund });
|
*/
|
||||||
|
function merkeUnzugeordnet(
|
||||||
|
bericht: SyncReport,
|
||||||
|
gesammelt: Map<string, UnassignedItem>,
|
||||||
|
item: JellyfinItem,
|
||||||
|
tmdbId: number | null,
|
||||||
|
grund: string,
|
||||||
|
) {
|
||||||
|
// Bei Episoden zählt die Serie: einzelne Folgen einer unbekannten Serie
|
||||||
|
// ergeben keine sinnvolle Auswahl, die Serie schon.
|
||||||
|
const istEpisode = item.Type === "Episode";
|
||||||
|
// Der Typ kommt aus dem Item, nicht aus dem Umkehrschluss „keine Episode,
|
||||||
|
// also Film". Jellyfin liefert bei dieser Abfrage zwar nur Movie und
|
||||||
|
// Episode, aber ein Server, der auch Series mitschickt, bekäme sonst
|
||||||
|
// „Film" ans Ohr geheftet.
|
||||||
|
const istSerie = istEpisode || item.Type === "Series";
|
||||||
|
const name = istEpisode ? (item.SeriesName ?? item.Name ?? "Ohne Titel") : (item.Name ?? "Ohne Titel");
|
||||||
|
const schluessel = tmdbId != null ? `tmdb:${tmdbId}` : `name:${name.toLocaleLowerCase("de-DE")}`;
|
||||||
|
|
||||||
|
const vorhanden = gesammelt.get(schluessel);
|
||||||
|
if (vorhanden) {
|
||||||
|
vorhanden.dateien += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gesammelt.size >= BERICHT_GRENZE) {
|
||||||
|
bericht.weitereNichtZugeordnet += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gesammelt.set(schluessel, {
|
||||||
|
tmdbId,
|
||||||
|
typ: tmdbId == null ? null : istSerie ? "SERIES" : "MOVIE",
|
||||||
|
name,
|
||||||
|
grund,
|
||||||
|
dateien: 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -429,6 +508,110 @@ async function uebernehmeSichtungen(
|
|||||||
return angelegt;
|
return angelegt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt ausgewählte Titel aus TMDB nach und nimmt sie in die Bibliothek auf.
|
||||||
|
*
|
||||||
|
* Bewusst getrennt vom Abgleich: der läuft nachts und soll nie ungefragt
|
||||||
|
* hunderte Titel importieren. Das hier passiert nur auf Klick, mit einer
|
||||||
|
* Auswahl, die jemand getroffen hat.
|
||||||
|
*
|
||||||
|
* Ein einzelner Fehlschlag beendet den Lauf nicht — TMDB kennt nicht jede
|
||||||
|
* Kennung, die in einer Mediathek steht, und ein unbekannter Film soll die
|
||||||
|
* anderen neunundneunzig nicht mitreißen.
|
||||||
|
*/
|
||||||
|
export async function importUnassigned(
|
||||||
|
userId: string,
|
||||||
|
connectionId: string,
|
||||||
|
auswahl: { tmdbId: number; typ: "SERIES" | "MOVIE" }[],
|
||||||
|
): Promise<{ nachgeladen: number; fehler: string[]; uebrig: number }> {
|
||||||
|
await requireConnection(userId, connectionId);
|
||||||
|
|
||||||
|
if (!env.tmdb.isConfigured) {
|
||||||
|
throw new MediaServerError(
|
||||||
|
"Ohne TMDB-Schlüssel lassen sich keine Titel nachladen. Trage TMDB_API_KEY in der .env ein.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obergrenze je Lauf: 100 Serien bedeuten mit Staffeln und Episoden schnell
|
||||||
|
// mehrere hundert TMDB-Abrufe. Der Rest kommt beim nächsten Mal.
|
||||||
|
const stapel = auswahl.slice(0, IMPORT_GRENZE);
|
||||||
|
const uebrig = Math.max(0, auswahl.length - stapel.length);
|
||||||
|
|
||||||
|
const fehler: string[] = [];
|
||||||
|
const geholt: number[] = [];
|
||||||
|
|
||||||
|
for (const eintrag of stapel) {
|
||||||
|
try {
|
||||||
|
const titel = await ensureTitle(eintrag.typ, eintrag.tmdbId, { region: env.tmdb.region });
|
||||||
|
|
||||||
|
// Aufnehmen und gleich als vorhanden markieren — er liegt ja auf dem
|
||||||
|
// Server. Ein bestehender Eintrag wird dabei nicht überschrieben.
|
||||||
|
const vorhanden = await prisma.libraryEntry.findUnique({
|
||||||
|
where: { userId_titleId: { userId, titleId: titel.id } },
|
||||||
|
select: { id: true, owned: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vorhanden) {
|
||||||
|
await prisma.libraryEntry.create({
|
||||||
|
data: { userId, titleId: titel.id, status: "WATCHLIST", owned: true, ownedSource: "Jellyfin" },
|
||||||
|
});
|
||||||
|
} else if (!vorhanden.owned) {
|
||||||
|
await prisma.libraryEntry.update({
|
||||||
|
where: { id: vorhanden.id },
|
||||||
|
data: { owned: true, ownedSource: "Jellyfin" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
geholt.push(eintrag.tmdbId);
|
||||||
|
} catch (error) {
|
||||||
|
fehler.push(
|
||||||
|
`TMDB ${eintrag.tmdbId}: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur was tatsächlich ankam, verschwindet aus der Auswahlliste. Ein
|
||||||
|
// Fehlschlag — TMDB kurz nicht erreichbar, Kennung unbekannt — soll den
|
||||||
|
// Eintrag stehen lassen, damit man es noch einmal versuchen kann, ohne
|
||||||
|
// vorher den ganzen Abgleich zu wiederholen.
|
||||||
|
await entferneAusBericht(connectionId, geholt);
|
||||||
|
|
||||||
|
return { nachgeladen: geholt.length, fehler, uebrig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streicht die geholten Titel aus dem gespeicherten Bericht.
|
||||||
|
*
|
||||||
|
* Ohne das stünden sie nach dem Import weiter in der Auswahlliste, und man
|
||||||
|
* würde sie beim nächsten Mal erneut anhaken.
|
||||||
|
*/
|
||||||
|
async function entferneAusBericht(connectionId: string, tmdbIds: number[]) {
|
||||||
|
if (tmdbIds.length === 0) return;
|
||||||
|
|
||||||
|
const verbindung = await prisma.mediaServerConnection.findUnique({
|
||||||
|
where: { id: connectionId },
|
||||||
|
select: { lastSyncReport: true },
|
||||||
|
});
|
||||||
|
const bericht = verbindung?.lastSyncReport as SyncReport | null;
|
||||||
|
if (!bericht?.nichtZugeordnet) return;
|
||||||
|
|
||||||
|
const geholt = new Set(tmdbIds);
|
||||||
|
const rest = bericht.nichtZugeordnet.filter(
|
||||||
|
(eintrag) => eintrag.tmdbId == null || !geholt.has(eintrag.tmdbId),
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.mediaServerConnection.update({
|
||||||
|
where: { id: connectionId },
|
||||||
|
data: {
|
||||||
|
lastSyncReport: {
|
||||||
|
...bericht,
|
||||||
|
nichtZugeordnet: rest,
|
||||||
|
nachgeladen: (bericht.nachgeladen ?? 0) + tmdbIds.length,
|
||||||
|
} as object,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle fälligen Verbindungen abgleichen — für den täglichen Lauf.
|
* Alle fälligen Verbindungen abgleichen — für den täglichen Lauf.
|
||||||
*
|
*
|
||||||
|
|||||||
Vendored
+11
@@ -77,6 +77,17 @@ export const items = {
|
|||||||
ProviderIds: { Tmdb: "424242" },
|
ProviderIds: { Tmdb: "424242" },
|
||||||
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
|
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Zweite Datei zu demselben fremden Film — so führt Jellyfin Extended
|
||||||
|
* Editions. In der Auswahlliste muss der Titel trotzdem einmal stehen.
|
||||||
|
*/
|
||||||
|
fremdZweiteFassung: {
|
||||||
|
Id: "jf-movie-5",
|
||||||
|
Name: "Ein fremder Film (Extended)",
|
||||||
|
Type: "Movie",
|
||||||
|
ProviderIds: { Tmdb: "424242" },
|
||||||
|
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const seriesItem = {
|
export const seriesItem = {
|
||||||
|
|||||||
+153
-1
@@ -12,6 +12,7 @@ import { decryptSecret, encryptSecret } from "../src/lib/secrets";
|
|||||||
import {
|
import {
|
||||||
connectJellyfin,
|
connectJellyfin,
|
||||||
deleteConnection,
|
deleteConnection,
|
||||||
|
importUnassigned,
|
||||||
syncConnection,
|
syncConnection,
|
||||||
testConnection,
|
testConnection,
|
||||||
} from "../src/lib/mediaserver";
|
} from "../src/lib/mediaserver";
|
||||||
@@ -140,7 +141,7 @@ test("Bestand wird zugeordnet, Unzuordenbares landet im Bericht", async () => {
|
|||||||
|
|
||||||
const bericht = await syncConnection(userId, verbindung.id);
|
const bericht = await syncConnection(userId, verbindung.id);
|
||||||
|
|
||||||
assert.equal(bericht.gelesen, 6, "alle Items wurden gelesen");
|
assert.equal(bericht.gelesen, 7, "alle Items wurden gelesen");
|
||||||
assert.equal(bericht.aufgenommen, 0, "ohne ausdrückliche Erlaubnis wird nichts aufgenommen");
|
assert.equal(bericht.aufgenommen, 0, "ohne ausdrückliche Erlaubnis wird nichts aufgenommen");
|
||||||
assert.equal(bericht.bestandGesetzt, 1, "nur der vorhandene Eintrag bekommt „hab ich“");
|
assert.equal(bericht.bestandGesetzt, 1, "nur der vorhandene Eintrag bekommt „hab ich“");
|
||||||
|
|
||||||
@@ -160,6 +161,12 @@ test("Bestand wird zugeordnet, Unzuordenbares landet im Bericht", async () => {
|
|||||||
gruende.some((grund) => grund.includes("nicht im Katalog")),
|
gruende.some((grund) => grund.includes("nicht im Katalog")),
|
||||||
"der fremde Film steht im Bericht",
|
"der fremde Film steht im Bericht",
|
||||||
);
|
);
|
||||||
|
// Die zwei Dateien zum fremden Film stehen als ein Eintrag da.
|
||||||
|
assert.equal(
|
||||||
|
bericht.nichtZugeordnet.length,
|
||||||
|
2,
|
||||||
|
"zusammengefasst: ein fremder Film und ein Video ohne Kennung",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
jf.restore();
|
jf.restore();
|
||||||
}
|
}
|
||||||
@@ -315,3 +322,148 @@ test("fremde Verbindungen sind unerreichbar", async () => {
|
|||||||
jf.restore();
|
jf.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Nachladen unbekannter Titel (#16) ---------------------------------------
|
||||||
|
|
||||||
|
test("der Bericht fasst Dubletten zusammen und nennt Typ und Kennung", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
const bericht = await syncConnection(userId, verbindung.id);
|
||||||
|
|
||||||
|
// Zwei Jellyfin-Dateien zeigen auf denselben fremden Film.
|
||||||
|
const fremd = bericht.nichtZugeordnet.filter((eintrag) => eintrag.tmdbId === 424242);
|
||||||
|
assert.equal(fremd.length, 1, "derselbe Titel steht nur einmal in der Liste");
|
||||||
|
assert.equal(fremd[0]!.dateien, 2, "beide Dateien werden gezaehlt");
|
||||||
|
assert.equal(fremd[0]!.typ, "MOVIE");
|
||||||
|
|
||||||
|
// Das Urlaubsvideo hat keine Kennung — es steht drin, aber ohne Typ.
|
||||||
|
const ohneKennung = bericht.nichtZugeordnet.filter((eintrag) => eintrag.tmdbId === null);
|
||||||
|
assert.equal(ohneKennung.length, 1);
|
||||||
|
assert.equal(ohneKennung[0]!.typ, null, "ohne Kennung laesst sich nichts holen");
|
||||||
|
|
||||||
|
assert.equal(bericht.weitereNichtZugeordnet, 0, "nichts abgeschnitten");
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ausgewaehlte Titel werden aus TMDB nachgeladen", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
await syncConnection(userId, verbindung.id);
|
||||||
|
|
||||||
|
// MOVIE_2_ID kennt der TMDB-Stub, steht aber in diesem Lauf nicht in der
|
||||||
|
// Bibliothek — genau der Fall, den #16 loest.
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
|
||||||
|
|
||||||
|
const ergebnis = await importUnassigned(userId, verbindung.id, [
|
||||||
|
{ tmdbId: MOVIE_2_ID, typ: "MOVIE" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(ergebnis.nachgeladen, 1);
|
||||||
|
assert.deepEqual(ergebnis.fehler, []);
|
||||||
|
assert.equal(ergebnis.uebrig, 0);
|
||||||
|
|
||||||
|
const titel = await prisma.title.findFirst({ where: { tmdbId: MOVIE_2_ID } });
|
||||||
|
assert.ok(titel, "der Titel steht jetzt im Katalog");
|
||||||
|
|
||||||
|
const eintrag = await prisma.libraryEntry.findFirstOrThrow({
|
||||||
|
where: { userId, titleId: titel.id },
|
||||||
|
select: { status: true, owned: true, ownedSource: true },
|
||||||
|
});
|
||||||
|
assert.equal(eintrag.status, "WATCHLIST");
|
||||||
|
assert.equal(eintrag.owned, true, "was auf dem Server liegt, ist vorhanden");
|
||||||
|
assert.equal(eintrag.ownedSource, "Jellyfin");
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ein einzelner Fehlschlag bricht den Import nicht ab", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
|
||||||
|
|
||||||
|
const ergebnis = await importUnassigned(userId, verbindung.id, [
|
||||||
|
// Diese Kennung kennt der Stub nicht — sie muss scheitern.
|
||||||
|
{ tmdbId: 424242, typ: "MOVIE" },
|
||||||
|
{ tmdbId: MOVIE_2_ID, typ: "MOVIE" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(ergebnis.nachgeladen, 1, "der zweite Titel kommt trotzdem an");
|
||||||
|
assert.equal(ergebnis.fehler.length, 1);
|
||||||
|
assert.ok(ergebnis.fehler[0]!.includes("424242"));
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nachgeladene Titel verschwinden aus der Auswahlliste", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
await syncConnection(userId, verbindung.id);
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
|
||||||
|
|
||||||
|
const vorher = await gespeicherterBericht(verbindung.id);
|
||||||
|
assert.ok(vorher.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242));
|
||||||
|
|
||||||
|
await importUnassigned(userId, verbindung.id, [{ tmdbId: MOVIE_2_ID, typ: "MOVIE" }]);
|
||||||
|
// Der fremde Film wurde nicht ausgewaehlt und bleibt stehen …
|
||||||
|
const nachher = await gespeicherterBericht(verbindung.id);
|
||||||
|
assert.ok(nachher.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242));
|
||||||
|
|
||||||
|
// Ein Fehlschlag laesst den Eintrag dagegen stehen: TMDB kennt 424242
|
||||||
|
// nicht, und wer es spaeter erneut versuchen will, soll ihn noch finden.
|
||||||
|
const misslungen = await importUnassigned(userId, verbindung.id, [
|
||||||
|
{ tmdbId: 424242, typ: "MOVIE" },
|
||||||
|
]);
|
||||||
|
assert.equal(misslungen.nachgeladen, 0);
|
||||||
|
assert.equal(misslungen.fehler.length, 1);
|
||||||
|
|
||||||
|
const zuletzt = await gespeicherterBericht(verbindung.id);
|
||||||
|
assert.ok(
|
||||||
|
zuletzt.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242),
|
||||||
|
"was nicht geholt werden konnte, bleibt in der Liste",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fremde Verbindungen lassen sich nicht befuellen", async () => {
|
||||||
|
const jf = installJellyfinStub();
|
||||||
|
try {
|
||||||
|
const verbindung = await verbinden();
|
||||||
|
const fremderId = (
|
||||||
|
await prisma.user.create({
|
||||||
|
data: { email: "test-mediaserver-import@flimmer.invalid", name: "Fremd" },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => importUnassigned(fremderId, verbindung.id, [{ tmdbId: MOVIE_2_ID, typ: "MOVIE" }]),
|
||||||
|
/gibt es nicht/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await prisma.user.delete({ where: { id: fremderId } });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
jf.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function gespeicherterBericht(connectionId: string) {
|
||||||
|
const verbindung = await prisma.mediaServerConnection.findUniqueOrThrow({
|
||||||
|
where: { id: connectionId },
|
||||||
|
select: { lastSyncReport: true },
|
||||||
|
});
|
||||||
|
return verbindung.lastSyncReport as unknown as {
|
||||||
|
nichtZugeordnet: { tmdbId: number | null }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user