Feat #16: Unbekannte Jellyfin-Titel zur Auswahl stellen und nachladen #18
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
Vendored
+11
@@ -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
@@ -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 }[];
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user