4 Commits
Author SHA1 Message Date
florian.wilde 57151a770e Merge #19: Version 0.9.0
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s
2026-09-02 12:47:52 +00:00
Florian Wilde 09625c89ef Version 0.9.0
CI / pruefen (pull_request) Canceled after 0s
Errungenschaften und die Auswahlliste fuer unbekannte Jellyfin-Titel.
Changelog, Release-Notiz, DEVLOG und Projektgedaechtnis nachgezogen.

Die Anleitung hat ihre beiden Abschnitte schon mit den jeweiligen Issues
bekommen — Errungenschaften mit Screenshot, „Titel, die Flimmer noch nicht
kennt" im Jellyfin-Kapitel.
2026-09-02 14:47:23 +02:00
florian.wilde f8d5566865 Merge #18: Unbekannte Jellyfin-Titel zur Auswahl stellen und nachladen
CI / pruefen (push) Canceled after 0s
Closes #16
2026-09-02 11:27:26 +00:00
Florian Wilde 7f890155ae Unbekannte Jellyfin-Titel zur Auswahl stellen und nachladen
CI / pruefen (pull_request) Canceled after 0s
Der Abgleich ordnete nur gegen Titel zu, die Flimmer schon kannte. Auf einer
frisch aufgesetzten Instanz meldet Jellyfin damit tausend Eintraege und
Flimmer ordnet keinen einzigen zu — der Bericht konnte nur aufzaehlen, was er
nicht einsortieren konnte.

Aus dem Bericht wird eine Auswahlliste: anhaken, was uebernommen werden soll,
und nur das aus TMDB nachladen. Ein pauschales Haekchen waere bei tausend
Eintraegen keine Wahl gewesen — in einer Mediathek liegen Konzertmitschnitte
neben dem, was man wirklich verfolgt.

Der Bericht ist dafuer umgebaut:

- Zusammengefasst je TMDB-Kennung. Jellyfin fuehrt oft mehrere Dateien zum
  selben Film; in der Liste steht er einmal, mit der Zahl der Dateien.
- Die Grenze stand bei 50 und war bei ueber tausend gelesenen Eintraegen ein
  Blick durchs Schluesselloch. Jetzt 1000, und was darueber hinausgeht, wird
  gezaehlt statt stillschweigend abgeschnitten.
- Der Typ kommt aus dem Item statt aus dem Umkehrschluss "keine Episode, also
  Film". Eine Probe zeigte sonst eine Serie als Film.

Nachgeladen wird nur auf Klick, hoechstens hundert je Lauf, und ein einzelner
Fehlschlag beendet den Lauf nicht. Aus der Liste verschwindet nur, was
tatsaechlich ankam: ein Fehlschlag laesst den Eintrag stehen, damit man es
erneut versuchen kann, ohne den ganzen Abgleich zu wiederholen. Mein erster
Entwurf strich auch Gescheiterte — der Test hielt das falsche Verhalten fest
und wurde mitkorrigiert.

Eintraege ohne TMDB-Kennung bleiben sichtbar, aber nicht auswaehlbar: dort
fehlt die Kennung in Jellyfin selbst, da kann Flimmer nichts holen.

Fuenf Tests dazu. Der Importschritt selbst laesst sich lokal nur ueber die
Domaenenschicht pruefen — der Entwicklungs-Stub speist sich aus dem Katalog
und kann per Definition nichts liefern, was dort fehlt. Der Klickweg belegt
Liste, Entdopplung, Suche, Auswahl, die gesperrten Zeilen und die ehrliche
Fehlermeldung.

Refs #16
2026-09-02 13:12:19 +02:00
12 changed files with 838 additions and 16 deletions
+93
View File
@@ -7,6 +7,99 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.9.0] — 2026-09-02
**Errungenschaften** — Abzeichen für erreichte Meilensteine. Dazu die
**Auswahlliste** beim Jellyfin-Abgleich, ohne die die Anbindung auf einer
gewachsenen Mediathek kaum etwas ausrichtete.
> ⚠️ **Beim Aktualisieren kommt eine Migration mit** (`errungenschaften`). Der
> Container spielt sie beim Start selbst ein; `./update.sh` legt vorher eine
> Sicherung an.
### Hinzugefügt
- **Errungenschaften.** Zwanzig feste Abzeichen, dazu dynamische für
vollständig gesehene Filmreihen und abgeschlossene Universen. Zu finden
unten auf der Statistikseite.
Sie tauchen auf, wenn etwas erreicht ist, und bleiben dann — **keine
Streaks**, die reißen, wenn man eine Woche nichts schaut, keine
Fortschrittsbalken, die drängeln. Wer eine Sichtung zurücknimmt, verliert
kein Abzeichen; es wieder einzukassieren wäre kleinlich.
Ein paar davon: *Der lange Atem* (eine Serie mit hundert Episoden komplett),
*Treu geblieben* (über ein Jahr an demselben Titel), *Durchgemacht*
(zwischen drei und fünf Uhr morgens abgehakt), *Zeitreisender* (Titel aus
sieben Jahrzehnten), *Strenges Urteil* (einmal die Note 1 vergeben).
Für **ganze Filmreihen** braucht es keine gepflegte Liste: TMDB gruppiert
sie selbst, und die Zugehörigkeit steht beim Import schon am Titel. Wer alle
Spider-Man-Filme gesehen hat, bekommt sie als Reihe angerechnet. Für
bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht „Reihe
komplett — …".
Beim ersten Öffnen wird die ganze Historie einmal **stumm** ausgewertet —
eine gewachsene Bibliothek soll nicht zwei Dutzend Meldungen auf einmal
auslösen. Erst danach wird gemeldet, was neu dazukommt. Auf dem öffentlichen
Profil lassen sich die Abzeichen zeigen oder ausblenden.
- **Auswahlliste für Jellyfin-Titel, die Flimmer nicht kennt.** Beim ersten
Abgleich einer gewachsenen Mediathek passierte bisher etwas Ernüchterndes:
Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen einzigen** zu,
weil der Katalog sie nicht kennt.
Jetzt steht unter dem Bericht eine Liste zum Anhaken. Was ausgewählt ist,
holt Flimmer von TMDB — bei Serien samt Staffeln und Episoden — und nimmt es
mit „hab ich · Jellyfin" in die Bibliothek auf.
Die Liste fasst zusammen: Führt Jellyfin drei Dateien zum selben Film
(Extended Edition, zweite Fassung), steht er einmal da, mit dem Vermerk, wie
viele Dateien darauf zeigen. Einträge, denen in Jellyfin die TMDB-Kennung
fehlt, bleiben sichtbar, sind aber nicht auswählbar — dort kann Flimmer
nichts holen.
Geholt werden hundert je Lauf; der Rest bleibt für den nächsten Klick. Ein
einzelner Fehlschlag beendet den Lauf nicht, und **aus der Liste verschwindet
nur, was tatsächlich ankam**.
### Geändert
- Der Bericht des Medienserver-Abgleichs war auf **50 Einträge** gekürzt —
bei über tausend gelesenen ein Blick durchs Schlüsselloch. Jetzt bis 1000,
und was darüber hinausgeht, wird gezählt statt stillschweigend
abgeschnitten.
- Das Häkchen beim Abgleich heißt jetzt „**Bekannte** Titel dabei in die
Bibliothek aufnehmen". Es nimmt auf, was Flimmer *kennt*; die neue
Auswahlliste holt, was es *nicht kennt*. Der Unterschied stand vorher
nirgends — und war der Grund, warum das Häkchen bei einer frischen Instanz
nichts bewirkte.
- Einblendungen kennen einen dritten Ton für Erfolge.
- `npm run screenshots` hebt für Ausschnittaufnahmen die klebende Kopfleiste
auf; sie legte sich sonst über die Überschrift.
### Behoben
- Im Bericht stand eine Serie als **Film**, weil der Typ aus dem Umkehrschluss
„keine Episode, also Film" abgeleitet wurde. Er kommt jetzt aus dem Item.
### Getestet
93 Tests (vorher 78). Neu: der Regelkatalog der Errungenschaften samt
Stummschaltung, Entdopplung und Zeitzonenrechnung; die Auswahlliste samt
Zusammenfassung, Fehlschlägen und Rechteprüfung.
Ein Test hat dabei einen Fehler gefunden, den man leicht übersieht: Die Regel
„zwischen drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette,
und auf Deutsch liefert das System dafür **„03 Uhr"** — als Zahl gelesen wird
daraus nichts. Das Abzeichen wäre nie vergeben worden.
### Anleitung
`docs/ANLEITUNG.md` hat zwei neue Abschnitte: **Errungenschaften** mit
Screenshot und **Titel, die Flimmer noch nicht kennt** im Jellyfin-Kapitel.
## [0.8.0] — 2026-09-02 ## [0.8.0] — 2026-09-02
Zwei größere Bausteine: **Anmeldung per Passkey** und die **Jellyfin-Anbindung**. Zwei größere Bausteine: **Anmeldung per Passkey** und die **Jellyfin-Anbindung**.
+2
View File
@@ -115,6 +115,8 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
- ✅ **Nacharbeiten** (v0.7.0/0.7.1) — Docker verifiziert, „hab ich", `update.sh`. 51 min. - ✅ **Nacharbeiten** (v0.7.0/0.7.1) — Docker verifiziert, „hab ich", `update.sh`. 51 min.
- ✅ **Issue-Runde** (v0.8.0) — Passkeys, Jellyfin-Anbindung, Standardreihenfolge, - ✅ **Issue-Runde** (v0.8.0) — Passkeys, Jellyfin-Anbindung, Standardreihenfolge,
Rückmeldung beim Tippen, Titelraster, Filmsichtung zurücknehmen. 77 min. Rückmeldung beim Tippen, Titelraster, Filmsichtung zurücknehmen. 77 min.
- ✅ **Zweite Runde** (v0.9.0) — Errungenschaften, Auswahlliste für unbekannte
Jellyfin-Titel. 37 min.
Ab v0.8.0 wird **issue-getrieben** gearbeitet: eigener Branch je Issue, Ab v0.8.0 wird **issue-getrieben** gearbeitet: eigener Branch je Issue,
Zeiterfassung in Gitea, Freigabe durch Florian. Details im DEVLOG. Zeiterfassung in Gitea, Freigabe durch Florian. Details im DEVLOG.
+37 -1
View File
@@ -146,6 +146,41 @@ selbst — Changelog, Screenshots, Release.
--- ---
## Zweite Issue-Runde (v0.9.0)
| # | Issue | Dauer | Was passiert ist |
|---|---|---|---|
| 9.1 | #15 Errungenschaften | 22 min | Regelkatalog auf einem gemeinsamen Datensatz; Filmreihen über die TMDB-Zugehörigkeit statt einer gepflegten Liste |
| 9.2 | #16 Titel nachladen | 15 min | Aus dem Bericht wird eine Auswahlliste; was Flimmer nicht kennt, kommt auf Wunsch aus TMDB |
**Issues gesamt: 37 min** (2026-09-02, 09:47 – 11:13)
Dazu ohne Zeiterfassung: zwei Issues angelegt (#15 und #16), diese Version
selbst und die Nachträge an der Anleitung.
### Was dabei auffiel
1. **Ein Test hat eine Formatierungsfalle aufgedeckt.** Die Regel „zwischen
drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette; auf
Deutsch liefert `Intl` dafür „03 Uhr", und `Number("03 Uhr")` ist `NaN`.
Das Abzeichen wäre nie vergeben worden.
2. **Ich hatte ein fragwürdiges Verhalten in einem Test festgeschrieben.**
Der erste Entwurf strich auch *gescheiterte* Titel aus der Auswahlliste,
und der Test hielt das fest. Erst beim Durchklicken fiel auf, wie ärgerlich
das wäre — Code und Test wurden korrigiert.
3. **Ein Klickweg hat eine falsche Beschriftung gezeigt.** Eine Serie stand
als „Film" in der Liste, weil der Typ aus dem Umkehrschluss „keine Episode,
also Film" kam.
4. **Der Entwicklungs-Stub kann nur suchen.** Er speist sich aus dem lokalen
Katalog und kann deshalb per Definition nichts liefern, was dort fehlt —
der Importpfad ließ sich nur über Fixtures belegen, nicht über die
Oberfläche.
5. **Zwei parallele Branches, kein Konflikt.** Beide zweigten von `main` ab
und fassten dieselben drei Dateien an. Der Merge lief glatt; geprüft wurde
trotzdem die Kombination auf `main`, nicht nur die einzelnen Zweige.
---
## Gesamtübersicht ## Gesamtübersicht
| Etappe | Version | Dauer | | Etappe | Version | Dauer |
@@ -158,8 +193,9 @@ selbst — Changelog, Screenshots, Release.
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min | | 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min | | Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
| Issue-Runde — Passkeys, Jellyfin, vier Nacharbeiten | 0.8.0 | 77 min | | Issue-Runde — Passkeys, Jellyfin, vier Nacharbeiten | 0.8.0 | 77 min |
| Zweite Runde — Errungenschaften, Titel nachladen | 0.9.0 | 37 min |
**Gesamtentwicklungszeit: 4 h 24 min** (264 min, 2026-09-01 18:09 – 2026-09-02 07:25) **Gesamtentwicklungszeit: 5 h 01 min** (301 min, 2026-09-01 18:09 – 2026-09-02 11:13)
### Womit die Zeit verging ### Womit die Zeit verging
+32
View File
@@ -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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.8.0", "version": "0.9.0",
"private": true, "private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT", "license": "MIT",
+91
View File
@@ -0,0 +1,91 @@
# Flimmer 0.9.0
**Errungenschaften** — Abzeichen für erreichte Meilensteine. Dazu die
**Auswahlliste** beim Jellyfin-Abgleich, ohne die die Anbindung auf einer
gewachsenen Mediathek kaum etwas ausrichtete.
> ⚠️ **Beim Aktualisieren kommt eine Migration mit** (`errungenschaften`). Der
> Container spielt sie beim Start selbst ein; `./update.sh` legt vorher eine
> Sicherung an.
### Hinzugefügt
- **Errungenschaften.** Zwanzig feste Abzeichen, dazu dynamische für
vollständig gesehene Filmreihen und abgeschlossene Universen. Zu finden
unten auf der Statistikseite.
Sie tauchen auf, wenn etwas erreicht ist, und bleiben dann — **keine
Streaks**, die reißen, wenn man eine Woche nichts schaut, keine
Fortschrittsbalken, die drängeln. Wer eine Sichtung zurücknimmt, verliert
kein Abzeichen; es wieder einzukassieren wäre kleinlich.
Ein paar davon: *Der lange Atem* (eine Serie mit hundert Episoden komplett),
*Treu geblieben* (über ein Jahr an demselben Titel), *Durchgemacht*
(zwischen drei und fünf Uhr morgens abgehakt), *Zeitreisender* (Titel aus
sieben Jahrzehnten), *Strenges Urteil* (einmal die Note 1 vergeben).
Für **ganze Filmreihen** braucht es keine gepflegte Liste: TMDB gruppiert
sie selbst, und die Zugehörigkeit steht beim Import schon am Titel. Wer alle
Spider-Man-Filme gesehen hat, bekommt sie als Reihe angerechnet. Für
bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht „Reihe
komplett — …".
Beim ersten Öffnen wird die ganze Historie einmal **stumm** ausgewertet —
eine gewachsene Bibliothek soll nicht zwei Dutzend Meldungen auf einmal
auslösen. Erst danach wird gemeldet, was neu dazukommt. Auf dem öffentlichen
Profil lassen sich die Abzeichen zeigen oder ausblenden.
- **Auswahlliste für Jellyfin-Titel, die Flimmer nicht kennt.** Beim ersten
Abgleich einer gewachsenen Mediathek passierte bisher etwas Ernüchterndes:
Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen einzigen** zu,
weil der Katalog sie nicht kennt.
Jetzt steht unter dem Bericht eine Liste zum Anhaken. Was ausgewählt ist,
holt Flimmer von TMDB — bei Serien samt Staffeln und Episoden — und nimmt es
mit „hab ich · Jellyfin" in die Bibliothek auf.
Die Liste fasst zusammen: Führt Jellyfin drei Dateien zum selben Film
(Extended Edition, zweite Fassung), steht er einmal da, mit dem Vermerk, wie
viele Dateien darauf zeigen. Einträge, denen in Jellyfin die TMDB-Kennung
fehlt, bleiben sichtbar, sind aber nicht auswählbar — dort kann Flimmer
nichts holen.
Geholt werden hundert je Lauf; der Rest bleibt für den nächsten Klick. Ein
einzelner Fehlschlag beendet den Lauf nicht, und **aus der Liste verschwindet
nur, was tatsächlich ankam**.
### Geändert
- Der Bericht des Medienserver-Abgleichs war auf **50 Einträge** gekürzt —
bei über tausend gelesenen ein Blick durchs Schlüsselloch. Jetzt bis 1000,
und was darüber hinausgeht, wird gezählt statt stillschweigend
abgeschnitten.
- Das Häkchen beim Abgleich heißt jetzt „**Bekannte** Titel dabei in die
Bibliothek aufnehmen". Es nimmt auf, was Flimmer *kennt*; die neue
Auswahlliste holt, was es *nicht kennt*. Der Unterschied stand vorher
nirgends — und war der Grund, warum das Häkchen bei einer frischen Instanz
nichts bewirkte.
- Einblendungen kennen einen dritten Ton für Erfolge.
- `npm run screenshots` hebt für Ausschnittaufnahmen die klebende Kopfleiste
auf; sie legte sich sonst über die Überschrift.
### Behoben
- Im Bericht stand eine Serie als **Film**, weil der Typ aus dem Umkehrschluss
„keine Episode, also Film" abgeleitet wurde. Er kommt jetzt aus dem Item.
### Getestet
93 Tests (vorher 78). Neu: der Regelkatalog der Errungenschaften samt
Stummschaltung, Entdopplung und Zeitzonenrechnung; die Auswahlliste samt
Zusammenfassung, Fehlschlägen und Rechteprüfung.
Ein Test hat dabei einen Fehler gefunden, den man leicht übersieht: Die Regel
„zwischen drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette,
und auf Deutsch liefert das System dafür **„03 Uhr"** — als Zahl gelesen wird
daraus nichts. Das Abzeichen wäre nie vergeben worden.
### Anleitung
`docs/ANLEITUNG.md` hat zwei neue Abschnitte: **Errungenschaften** mit
Screenshot und **Titel, die Flimmer noch nicht kennt** im Jellyfin-Kapitel.
+40
View File
@@ -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 },
+8
View File
@@ -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>
+175 -1
View File
@@ -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
View File
@@ -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.
* *
+11
View File
@@ -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
View File
@@ -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 }[];
};
}