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; }