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 | 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( { baseUrl, token }: ClientOptions, pfad: string, init: RequestInit = {}, ): Promise { 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> { const treffer = new Map(); 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 { return request({ baseUrl, token }, "/Users"); }