import type { JellyfinItem, JellyfinMediaStream, JellyfinUser, MediaStream } from "../types"; export interface JellyfinConfig { url: string; apiKey: string; /** Optional: when omitted the server-level /Items endpoint is used (requires admin API key). */ userId?: string; } /** Build the base items URL: user-scoped when userId is set, server-level otherwise. */ function itemsBaseUrl(cfg: JellyfinConfig): string { return cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items` : `${cfg.url}/Items`; } const PAGE_SIZE = 200; function headers(apiKey: string): Record { return { "X-Emby-Token": apiKey, "Content-Type": "application/json", }; } export async function testConnection(cfg: JellyfinConfig): Promise<{ ok: boolean; error?: string }> { try { const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey), }); if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; return { ok: true }; } catch (e) { return { ok: false, error: String(e) }; } } export async function getUsers(cfg: Pick): Promise { const res = await fetch(`${cfg.url}/Users`, { headers: headers(cfg.apiKey) }); if (!res.ok) throw new Error(`Jellyfin /Users failed: ${res.status}`); return res.json() as Promise; } const ITEM_FIELDS = [ "MediaStreams", "Path", "ProviderIds", "OriginalTitle", "ProductionYear", "Size", "Container", ].join(","); export async function* getAllItems( cfg: JellyfinConfig, onProgress?: (count: number, total: number) => void, ): AsyncGenerator { let startIndex = 0; let total = 0; do { const url = new URL(itemsBaseUrl(cfg)); url.searchParams.set("Recursive", "true"); url.searchParams.set("IncludeItemTypes", "Movie,Episode"); url.searchParams.set("Fields", ITEM_FIELDS); url.searchParams.set("Limit", String(PAGE_SIZE)); url.searchParams.set("StartIndex", String(startIndex)); const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) }); if (!res.ok) throw new Error(`Jellyfin items failed: ${res.status}`); const body = (await res.json()) as { Items: JellyfinItem[]; TotalRecordCount: number }; total = body.TotalRecordCount; for (const item of body.Items) { yield item; } startIndex += body.Items.length; onProgress?.(startIndex, total); } while (startIndex < total); } /** * Dev mode: yields 50 random movies + all episodes from 10 random series. * Used instead of getAllItems() when NODE_ENV=development. */ export async function* getDevItems(cfg: JellyfinConfig): AsyncGenerator { // 50 random movies const movieUrl = new URL(itemsBaseUrl(cfg)); movieUrl.searchParams.set("Recursive", "true"); movieUrl.searchParams.set("IncludeItemTypes", "Movie"); movieUrl.searchParams.set("SortBy", "Random"); movieUrl.searchParams.set("Limit", "50"); movieUrl.searchParams.set("Fields", ITEM_FIELDS); const movieRes = await fetch(movieUrl.toString(), { headers: headers(cfg.apiKey) }); if (!movieRes.ok) throw new Error(`Jellyfin movies failed: HTTP ${movieRes.status} — check JELLYFIN_URL and JELLYFIN_API_KEY`); const movieBody = (await movieRes.json()) as { Items: JellyfinItem[] }; for (const item of movieBody.Items) yield item; // 10 random series → yield all their episodes const seriesUrl = new URL(itemsBaseUrl(cfg)); seriesUrl.searchParams.set("Recursive", "true"); seriesUrl.searchParams.set("IncludeItemTypes", "Series"); seriesUrl.searchParams.set("SortBy", "Random"); seriesUrl.searchParams.set("Limit", "10"); const seriesRes = await fetch(seriesUrl.toString(), { headers: headers(cfg.apiKey) }); if (!seriesRes.ok) throw new Error(`Jellyfin series failed: HTTP ${seriesRes.status}`); const seriesBody = (await seriesRes.json()) as { Items: Array<{ Id: string }> }; for (const series of seriesBody.Items) { const epUrl = new URL(itemsBaseUrl(cfg)); epUrl.searchParams.set("ParentId", series.Id); epUrl.searchParams.set("Recursive", "true"); epUrl.searchParams.set("IncludeItemTypes", "Episode"); epUrl.searchParams.set("Fields", ITEM_FIELDS); const epRes = await fetch(epUrl.toString(), { headers: headers(cfg.apiKey) }); if (epRes.ok) { const epBody = (await epRes.json()) as { Items: JellyfinItem[] }; for (const ep of epBody.Items) yield ep; } } } /** Fetch a single Jellyfin item by its ID (for per-file rescan). */ export async function getItem(cfg: JellyfinConfig, jellyfinId: string): Promise { const base = cfg.userId ? `${cfg.url}/Users/${cfg.userId}/Items/${jellyfinId}` : `${cfg.url}/Items/${jellyfinId}`; const url = new URL(base); url.searchParams.set("Fields", ITEM_FIELDS); const res = await fetch(url.toString(), { headers: headers(cfg.apiKey) }); if (!res.ok) return null; return res.json() as Promise; } /** * Trigger a Jellyfin metadata refresh for a single item and wait until it completes. * Polls DateLastRefreshed until it changes (or timeout is reached). */ export async function refreshItem(cfg: JellyfinConfig, jellyfinId: string, timeoutMs = 15000): Promise { const itemUrl = `${cfg.url}/Items/${jellyfinId}`; // 1. Snapshot current DateLastRefreshed const beforeRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) }); if (!beforeRes.ok) throw new Error(`Jellyfin item fetch failed: HTTP ${beforeRes.status}`); const before = (await beforeRes.json()) as { DateLastRefreshed?: string }; const beforeDate = before.DateLastRefreshed; // 2. Trigger refresh (returns 204 immediately; refresh runs async) const refreshUrl = new URL(`${itemUrl}/Refresh`); refreshUrl.searchParams.set("MetadataRefreshMode", "FullRefresh"); refreshUrl.searchParams.set("ImageRefreshMode", "None"); refreshUrl.searchParams.set("ReplaceAllMetadata", "false"); refreshUrl.searchParams.set("ReplaceAllImages", "false"); const refreshRes = await fetch(refreshUrl.toString(), { method: "POST", headers: headers(cfg.apiKey) }); if (!refreshRes.ok) throw new Error(`Jellyfin refresh failed: HTTP ${refreshRes.status}`); // 3. Poll until DateLastRefreshed changes const start = Date.now(); while (Date.now() - start < timeoutMs) { await new Promise((r) => setTimeout(r, 1000)); const checkRes = await fetch(itemUrl, { headers: headers(cfg.apiKey) }); if (!checkRes.ok) continue; const check = (await checkRes.json()) as { DateLastRefreshed?: string }; if (check.DateLastRefreshed && check.DateLastRefreshed !== beforeDate) return; } // Timeout reached — proceed anyway (refresh may still complete in background) } /** Map a Jellyfin item to our normalized language code (ISO 639-2). */ export function extractOriginalLanguage(item: JellyfinItem): string | null { // Jellyfin doesn't have a direct "original_language" field like TMDb. // The best proxy is the language of the first audio stream. if (!item.MediaStreams) return null; const firstAudio = item.MediaStreams.find((s) => s.Type === "Audio"); return firstAudio?.Language ? normalizeLanguage(firstAudio.Language) : null; } /** Map a Jellyfin MediaStream to our internal MediaStream shape (sans id/item_id). */ export function mapStream(s: JellyfinMediaStream): Omit { return { stream_index: s.Index, type: s.Type as MediaStream["type"], codec: s.Codec ?? null, language: s.Language ? normalizeLanguage(s.Language) : null, language_display: s.DisplayLanguage ?? null, title: s.Title ?? null, is_default: s.IsDefault ? 1 : 0, is_forced: s.IsForced ? 1 : 0, is_hearing_impaired: s.IsHearingImpaired ? 1 : 0, channels: s.Channels ?? null, channel_layout: s.ChannelLayout ?? null, bit_rate: s.BitRate ?? null, sample_rate: s.SampleRate ?? null, }; } // ISO 639-2/T → ISO 639-2/B normalization + common aliases const LANG_ALIASES: Record = { // German: both /T (deu) and /B (ger) → deu ger: "deu", // Chinese chi: "zho", // French fre: "fra", // Dutch dut: "nld", // Modern Greek gre: "ell", // Hebrew heb: "heb", // Farsi per: "fas", // Romanian rum: "ron", // Malay may: "msa", // Tibetan tib: "bod", // Burmese bur: "mya", // Czech cze: "ces", // Slovak slo: "slk", // Georgian geo: "kat", // Icelandic ice: "isl", // Armenian arm: "hye", // Basque baq: "eus", // Albanian alb: "sqi", // Macedonian mac: "mkd", // Welsh wel: "cym", }; export function normalizeLanguage(lang: string): string { const lower = lang.toLowerCase().trim(); return LANG_ALIASES[lower] ?? lower; }