Zugeordnet wird ueber die TMDB-Kennung, die Jellyfin zu jedem Item mitliefert — dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut. Eine unscharfe Suche ueber Titel und Jahr ist damit nicht noetig: die Zuordnung ist eindeutig oder sie findet nicht statt. Was Jellyfin ohne Kennung fuehrt, landet im Bericht statt falsch zugeordnet zu werden. Angemeldet wird mit Benutzername und Passwort, nicht mit einem API-Schluessel. Mit einem Schluessel liefert Jellyfin den Bestand ohne UserData, also ohne Gesehen-Status — und zwar ohne Fehlermeldung. Der Verbindungstest prueft deshalb ausdruecklich nach und sagt es, statt den Abgleich stumm ins Leere laufen zu lassen. Das Zugangstoken liegt mit AES-256-GCM verschluesselt in der Datenbank; abgeleitet aus AUTH_SECRET. Der Abgleich ist eine Einbahnstrasse: er ergaenzt, was auf dem Server liegt oder dort gelaufen ist, und loescht nie etwas, das in Flimmer steht. Fehlende Titel werden nur aufgenommen, wenn das Haekchen gesetzt ist — im taeglichen Lauf also nie. Entdoppelt wird zweifach: Episoden ueber (userId, episodeId, run), Filme ausdruecklich per Nachschlagen. Bei Filmen ist episodeId null, und Postgres behandelt NULL im eindeutigen Index als jeweils verschieden — der Index greift dort nicht. Watch.source haelt fest, was von wo kam. Ohne diese Angabe liesse sich spaeter nicht mehr sagen, welche Sichtung aus Jellyfin stammt. Umgesetzt sind Stufe 1 und 2. Stufe 3 (Zurueckschreiben) kommt in der Abnahmeliste nicht vor und braeuchte eine Versionsweiche, die sich ohne echten Server nicht verlaesslich pruefen laesst; das Feld writeBack ist angelegt, die Logik dahinter bleibt leer. Elf Tests gegen nachgebildete Antworten. Zusaetzlich der ganze Klickweg gegen einen echten HTTP-Server im Produktionsbau: verbinden, pruefen, abgleichen, zweiter Lauf ohne Dubletten, und der Warnfall ohne UserData. Refs #1
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import "server-only";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Client für Jellyfin (und Emby — die API ist an den hier benutzten Stellen
|
||||
* dieselbe).
|
||||
*
|
||||
* Zwei Dinge, die beim Bauen Zeit gekostet hätten, wenn man sie nicht vorher
|
||||
* weiß:
|
||||
*
|
||||
* 1. **Mit einem reinen API-Key fehlt `UserData`.** Man bekommt den Bestand,
|
||||
* aber ohne Gesehen-Status — und ohne Fehlermeldung. Deshalb meldet sich
|
||||
* Flimmer über `AuthenticateByName` an und prüft beim Verbinden
|
||||
* ausdrücklich nach, ob `UserData` ankommt.
|
||||
* 2. Jellyfin erwartet einen `Authorization`-Kopf in einer eigenen Form
|
||||
* (`MediaBrowser Client=…`), nicht `Bearer`.
|
||||
*/
|
||||
|
||||
export class JellyfinError extends Error {}
|
||||
|
||||
/** Ein Item, so weit Flimmer es braucht. */
|
||||
export type JellyfinItem = {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Type: "Movie" | "Episode" | "Series" | string;
|
||||
ProviderIds?: Record<string, string> | null;
|
||||
/** Bei Episoden: die Serie darüber. */
|
||||
SeriesId?: string | null;
|
||||
SeriesName?: string | null;
|
||||
/** Staffel- und Episodennummer. */
|
||||
ParentIndexNumber?: number | null;
|
||||
IndexNumber?: number | null;
|
||||
ProductionYear?: number | null;
|
||||
RunTimeTicks?: number | null;
|
||||
UserData?: {
|
||||
Played?: boolean;
|
||||
PlayCount?: number;
|
||||
LastPlayedDate?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type JellyfinUser = { Id: string; Name: string };
|
||||
|
||||
type ClientOptions = { baseUrl: string; token: string };
|
||||
|
||||
/** Der Kopf, den Jellyfin zur Identifikation erwartet. */
|
||||
function authHeader(token?: string): string {
|
||||
const teile = [
|
||||
'Client="Flimmer"',
|
||||
`Device="${env.appName.replace(/"/g, "")}"`,
|
||||
'DeviceId="flimmer-sync"',
|
||||
'Version="1.0"',
|
||||
];
|
||||
if (token) teile.push(`Token="${token}"`);
|
||||
return `MediaBrowser ${teile.join(", ")}`;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
{ baseUrl, token }: ClientOptions,
|
||||
pfad: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = `${normalizeBaseUrl(baseUrl)}${pfad}`;
|
||||
|
||||
let antwort: Response;
|
||||
try {
|
||||
antwort = await fetch(url, {
|
||||
...init,
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: authHeader(token),
|
||||
...(init.body ? { "content-type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
// Der Bestand kann groß sein und der Server steht oft im selben Netz —
|
||||
// trotzdem nicht ewig warten.
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
} catch (error) {
|
||||
const grund = error instanceof Error ? error.message : "unbekannt";
|
||||
throw new JellyfinError(`Der Server ist nicht erreichbar (${grund}).`);
|
||||
}
|
||||
|
||||
if (antwort.status === 401) {
|
||||
throw new JellyfinError("Die Anmeldedaten wurden abgelehnt.");
|
||||
}
|
||||
if (!antwort.ok) {
|
||||
throw new JellyfinError(`Der Server antwortete mit ${antwort.status}.`);
|
||||
}
|
||||
|
||||
if (antwort.status === 204) return undefined as T;
|
||||
return (await antwort.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmelden mit Benutzername und Passwort.
|
||||
*
|
||||
* Bewusst nicht mit einem API-Key: der liefert keine `UserData` und damit
|
||||
* keinen Gesehen-Status.
|
||||
*/
|
||||
export async function authenticate(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ token: string; user: JellyfinUser; serverName: string }> {
|
||||
const antwort = await request<{
|
||||
AccessToken: string;
|
||||
User: JellyfinUser;
|
||||
ServerId?: string;
|
||||
}>(
|
||||
{ baseUrl, token: "" },
|
||||
"/Users/AuthenticateByName",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ Username: username, Pw: password }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!antwort.AccessToken) throw new JellyfinError("Der Server hat kein Zugangstoken geliefert.");
|
||||
|
||||
const info = await systemInfo(baseUrl, antwort.AccessToken).catch(() => null);
|
||||
|
||||
return {
|
||||
token: antwort.AccessToken,
|
||||
user: antwort.User,
|
||||
serverName: info?.ServerName ?? "Jellyfin",
|
||||
};
|
||||
}
|
||||
|
||||
export async function systemInfo(baseUrl: string, token: string) {
|
||||
return request<{ ServerName?: string; Version?: string }>(
|
||||
{ baseUrl, token },
|
||||
"/System/Info",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestand und Sichtungen eines Nutzers.
|
||||
*
|
||||
* `enableUserData=true` ist der entscheidende Parameter — ohne ihn (oder mit
|
||||
* einem API-Key statt eines Nutzertokens) fehlt der Gesehen-Status.
|
||||
*/
|
||||
export async function fetchItems(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
serverUserId: string,
|
||||
{ startIndex = 0, limit = 500 }: { startIndex?: number; limit?: number } = {},
|
||||
): Promise<{ items: JellyfinItem[]; total: number }> {
|
||||
const params = new URLSearchParams({
|
||||
userId: serverUserId,
|
||||
Recursive: "true",
|
||||
IncludeItemTypes: "Movie,Episode",
|
||||
Fields: "ProviderIds,ParentId",
|
||||
enableUserData: "true",
|
||||
EnableImages: "false",
|
||||
StartIndex: String(startIndex),
|
||||
Limit: String(limit),
|
||||
SortBy: "Id",
|
||||
SortOrder: "Ascending",
|
||||
});
|
||||
|
||||
const antwort = await request<{ Items?: JellyfinItem[]; TotalRecordCount?: number }>(
|
||||
{ baseUrl, token },
|
||||
`/Items?${params.toString()}`,
|
||||
);
|
||||
|
||||
return { items: antwort.Items ?? [], total: antwort.TotalRecordCount ?? 0 };
|
||||
}
|
||||
|
||||
/** Kopfdaten einer Serie — dort hängt die TMDB-ID, nicht an der Episode. */
|
||||
export async function fetchSeries(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
serverUserId: string,
|
||||
seriesIds: string[],
|
||||
): Promise<Map<string, JellyfinItem>> {
|
||||
const treffer = new Map<string, JellyfinItem>();
|
||||
if (seriesIds.length === 0) return treffer;
|
||||
|
||||
// In Blöcken abfragen: eine Adresse mit hunderten Kennungen wird sonst zu lang.
|
||||
for (let start = 0; start < seriesIds.length; start += 50) {
|
||||
const block = seriesIds.slice(start, start + 50);
|
||||
const params = new URLSearchParams({
|
||||
userId: serverUserId,
|
||||
Ids: block.join(","),
|
||||
Fields: "ProviderIds",
|
||||
EnableImages: "false",
|
||||
});
|
||||
|
||||
const antwort = await request<{ Items?: JellyfinItem[] }>(
|
||||
{ baseUrl, token },
|
||||
`/Items?${params.toString()}`,
|
||||
);
|
||||
for (const item of antwort.Items ?? []) treffer.set(item.Id, item);
|
||||
}
|
||||
|
||||
return treffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Verbindung — und ob der Gesehen-Status tatsächlich mitkommt.
|
||||
*
|
||||
* Genau hier liegt der Fallstrick: mit einem API-Key liefert Jellyfin den
|
||||
* Bestand ohne `UserData`. Ohne diese Prüfung liefe der Abgleich stumm ins
|
||||
* Leere und niemand wüsste, warum nichts als gesehen ankommt.
|
||||
*/
|
||||
export async function checkConnection(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
serverUserId: string,
|
||||
): Promise<{
|
||||
serverName: string;
|
||||
version: string | null;
|
||||
itemCount: number;
|
||||
hasUserData: boolean;
|
||||
withTmdbId: number;
|
||||
}> {
|
||||
const info = await systemInfo(baseUrl, token);
|
||||
const { items, total } = await fetchItems(baseUrl, token, serverUserId, { limit: 50 });
|
||||
|
||||
return {
|
||||
serverName: info.ServerName ?? "Jellyfin",
|
||||
version: info.Version ?? null,
|
||||
itemCount: total,
|
||||
hasUserData: items.length === 0 || items.some((item) => item.UserData != null),
|
||||
withTmdbId: items.filter((item) => tmdbIdOf(item) != null).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Die TMDB-ID aus den ProviderIds — Jellyfin schreibt den Schlüssel uneinheitlich. */
|
||||
export function tmdbIdOf(item: JellyfinItem): number | null {
|
||||
const ids = item.ProviderIds ?? {};
|
||||
const roh = ids.Tmdb ?? ids.TMDB ?? ids.tmdb ?? ids.TmdbId;
|
||||
if (!roh) return null;
|
||||
const zahl = Number.parseInt(String(roh), 10);
|
||||
return Number.isFinite(zahl) && zahl > 0 ? zahl : null;
|
||||
}
|
||||
|
||||
/** Läuft der Server unter dieser Adresse überhaupt? */
|
||||
export async function listUsers(baseUrl: string, token: string): Promise<JellyfinUser[]> {
|
||||
return request<JellyfinUser[]>({ baseUrl, token }, "/Users");
|
||||
}
|
||||
Reference in New Issue
Block a user