Feat #16: Unbekannte Jellyfin-Titel zur Auswahl stellen und nachladen #18

Merged
florian.wilde merged 1 commits from feat/16-titel-nachladen into main 2026-09-02 11:27:26 +00:00
7 changed files with 614 additions and 14 deletions
+32
View File
@@ -370,6 +370,38 @@ Beim Abgleich passiert dreierlei:
Danach läuft der Abgleich im täglichen Hintergrundlauf mit — dort allerdings
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,
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.
+40
View File
@@ -18,6 +18,7 @@ import { runSync } from "@/lib/sync";
import {
connectJellyfin,
deleteConnection,
importUnassigned,
syncConnection,
testConnection,
updateConnection,
@@ -402,6 +403,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(
connectionId: string,
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
+8
View File
@@ -89,6 +89,14 @@ export default async function SettingsPage() {
syncOwned: verbindung.syncOwned,
syncWatched: verbindung.syncWatched,
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>
+175 -1
View File
@@ -1,10 +1,12 @@
"use client";
import { useState, useTransition } from "react";
import { clsx } from "clsx";
import { useRouter } from "next/navigation";
import {
connectMediaServerAction,
deleteMediaServerAction,
importMediaServerTitlesAction,
syncMediaServerAction,
testMediaServerAction,
updateMediaServerAction,
@@ -26,6 +28,14 @@ import { formatDate } from "@/lib/labels";
* Bibliothek werden.
*/
export type UnassignedItem = {
tmdbId: number | null;
typ: "SERIES" | "MOVIE" | null;
name: string;
grund: string;
dateien: number;
};
export type Verbindung = {
id: string;
name: string;
@@ -35,6 +45,9 @@ export type Verbindung = {
syncOwned: boolean;
syncWatched: boolean;
lastSyncAt: string | null;
/** Aus dem letzten Abgleich — Grundlage der Auswahlliste. */
offen: UnassignedItem[];
weitereOffen: number;
};
type Pruefung = NonNullable<MediaServerResult["pruefung"]>;
@@ -171,7 +184,7 @@ export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung
Verbindung prüfen
</button>
<Haken
label="Fehlende Titel dabei aufnehmen"
label="Bekannte Titel dabei in die Bibliothek aufnehmen"
checked={aufnehmen}
onChange={setAufnehmen}
/>
@@ -184,6 +197,26 @@ export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung
{pruefung && <Pruefbericht pruefung={pruefung} />}
{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 && (
<div className="space-y-3">
<p className="text-sm text-[var(--fl-muted)]">
@@ -324,3 +357,144 @@ function Haken({
</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
View File
@@ -1,6 +1,8 @@
import "server-only";
import { prisma } from "./db";
import { env } from "./env";
import { ensureTitle } from "./catalog";
import { decryptSecret, encryptSecret } from "./secrets";
import {
authenticate,
@@ -33,12 +35,33 @@ import {
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 = {
gelesen: number;
bestandGesetzt: number;
sichtungenAngelegt: 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[];
};
@@ -47,12 +70,25 @@ const LEERER_BERICHT = (): SyncReport => ({
bestandGesetzt: 0,
sichtungenAngelegt: 0,
aufgenommen: 0,
nachgeladen: 0,
nichtZugeordnet: [],
weitereNichtZugeordnet: 0,
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
@@ -209,6 +245,8 @@ export async function syncConnection(
});
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 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);
if (tmdbId == null) {
merkeUnzugeordnet(bericht, item, "keine TMDB-Kennung am Item");
merkeUnzugeordnet(bericht, gesammelt, item, null, "keine TMDB-Kennung am Item");
continue;
}
const treffer = titelNachTmdb.get(tmdbId);
if (!treffer) {
merkeUnzugeordnet(bericht, item, `TMDB ${tmdbId} nicht im Katalog`);
merkeUnzugeordnet(bericht, gesammelt, item, tmdbId, "noch nicht im Katalog");
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({
where: { id: connectionId },
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;
}
function merkeUnzugeordnet(bericht: SyncReport, item: JellyfinItem, grund: string) {
if (bericht.nichtZugeordnet.length >= BERICHT_GRENZE) return;
const name =
item.Type === "Episode" && item.SeriesName
? `${item.SeriesName} — ${item.Name}`
: (item.Name ?? "Ohne Titel");
bericht.nichtZugeordnet.push({ name, grund });
/**
* Merkt sich einen nicht zuzuordnenden Titel — zusammengefasst.
*
* Der Schlüssel ist die TMDB-Kennung, sonst der Name. Jellyfin führt oft
* mehrere Dateien zum selben Film; in der Auswahlliste soll er einmal stehen,
* mit dem Vermerk, wie viele Dateien darauf zeigen.
*/
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;
}
/**
* 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.
*
+11
View File
@@ -77,6 +77,17 @@ export const items = {
ProviderIds: { Tmdb: "424242" },
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;
export const seriesItem = {
+153 -1
View File
@@ -12,6 +12,7 @@ import { decryptSecret, encryptSecret } from "../src/lib/secrets";
import {
connectJellyfin,
deleteConnection,
importUnassigned,
syncConnection,
testConnection,
} from "../src/lib/mediaserver";
@@ -140,7 +141,7 @@ test("Bestand wird zugeordnet, Unzuordenbares landet im Bericht", async () => {
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.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")),
"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 {
jf.restore();
}
@@ -315,3 +322,148 @@ test("fremde Verbindungen sind unerreichbar", async () => {
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 }[];
};
}