import fetch from "node-fetch"; import { JellyfinItemSummary, JellyfinStreamOption } from "./types"; const JELLYFIN_URL = (process.env.JELLYFIN_URL || "").replace(/\/+$/, ""); const JELLYFIN_API_KEY = process.env.JELLYFIN_API_KEY || ""; const JELLYFIN_USER_ID = process.env.JELLYFIN_USER_ID || ""; function assertConfigured(): void { if (!JELLYFIN_URL || !JELLYFIN_API_KEY || !JELLYFIN_USER_ID) { throw new Error( "Jellyfin isn't configured. Set JELLYFIN_URL, JELLYFIN_API_KEY, and JELLYFIN_USER_ID." ); } } function authHeaders(): Record { return { "X-Emby-Token": JELLYFIN_API_KEY, "Content-Type": "application/json", }; } /** Ask Jellyfin to rescan the library after a new file lands on disk. */ export async function refreshLibrary(): Promise { assertConfigured(); const res = await fetch(`${JELLYFIN_URL}/Library/Refresh`, { method: "POST", headers: authHeaders(), }); if (!res.ok) { throw new Error(`Jellyfin library refresh failed: ${res.status} ${res.statusText}`); } } /** * Find an item in the Jellyfin library by (approximate) title. Jellyfin needs * a moment after a refresh before a freshly-ripped file is indexed, so callers * should retry a few times if this comes back empty. */ export async function findItemByTitle(title: string): Promise { assertConfigured(); const params = new URLSearchParams({ searchTerm: title, IncludeItemTypes: "Movie", Recursive: "true", Fields: "Overview,ProductionYear,RunTimeTicks,MediaSources", Limit: "5", }); const res = await fetch( `${JELLYFIN_URL}/Users/${JELLYFIN_USER_ID}/Items?${params.toString()}`, { headers: authHeaders() } ); if (!res.ok) { throw new Error(`Jellyfin search failed: ${res.status} ${res.statusText}`); } const body = (await res.json()) as any; const item = body?.Items?.[0]; if (!item) return null; return { itemId: item.Id, name: item.Name, overview: item.Overview ?? null, year: item.ProductionYear ?? null, runtimeMinutes: item.RunTimeTicks ? Math.round(item.RunTimeTicks / 600000000) : null, posterUrl: `${JELLYFIN_URL}/Items/${item.Id}/Images/Primary?api_key=${JELLYFIN_API_KEY}`, streamOptions: buildStreamOptions(item), }; } /** Lists recently-added movies for the TrayFlix poster grid. */ export async function listLibrary(limit = 24): Promise { assertConfigured(); const params = new URLSearchParams({ IncludeItemTypes: "Movie", Recursive: "true", SortBy: "DateCreated", SortOrder: "Descending", Fields: "Overview,ProductionYear,RunTimeTicks,MediaSources", Limit: String(limit), }); const res = await fetch( `${JELLYFIN_URL}/Users/${JELLYFIN_USER_ID}/Items?${params.toString()}`, { headers: authHeaders() } ); if (!res.ok) { throw new Error(`Jellyfin library listing failed: ${res.status} ${res.statusText}`); } const body = (await res.json()) as any; const items: any[] = body?.Items || []; return items.map((item) => ({ itemId: item.Id, name: item.Name, overview: item.Overview ?? null, year: item.ProductionYear ?? null, runtimeMinutes: item.RunTimeTicks ? Math.round(item.RunTimeTicks / 600000000) : null, posterUrl: `${JELLYFIN_URL}/Items/${item.Id}/Images/Primary?api_key=${JELLYFIN_API_KEY}`, streamOptions: buildStreamOptions(item), })); } export async function getItemById(itemId: string): Promise { assertConfigured(); const params = new URLSearchParams({ Fields: "Overview,ProductionYear,RunTimeTicks,MediaSources" }); const res = await fetch( `${JELLYFIN_URL}/Users/${JELLYFIN_USER_ID}/Items/${itemId}?${params.toString()}`, { headers: authHeaders() } ); if (!res.ok) return null; const item = (await res.json()) as any; return { itemId: item.Id, name: item.Name, overview: item.Overview ?? null, year: item.ProductionYear ?? null, runtimeMinutes: item.RunTimeTicks ? Math.round(item.RunTimeTicks / 600000000) : null, posterUrl: `${JELLYFIN_URL}/Items/${item.Id}/Images/Primary?api_key=${JELLYFIN_API_KEY}`, streamOptions: buildStreamOptions(item), }; } /** Turn Jellyfin's MediaSources into simple resolution choices the UI can render as ticket stubs. */ function buildStreamOptions(item: any): JellyfinStreamOption[] { const sources: any[] = item?.MediaSources ?? []; if (sources.length === 0) { // Fall back to a single "Original" option driven straight through Jellyfin's transcoding. return [ { resolution: "Original", container: "mp4", bitrate: null, playUrl: buildPlayUrl(item.Id, "Original"), }, ]; } const options: JellyfinStreamOption[] = []; for (const source of sources) { const videoStream = (source.MediaStreams || []).find((s: any) => s.Type === "Video"); const height: number | undefined = videoStream?.Height; const label = height ? `${height}p` : "Original"; options.push({ resolution: label, container: source.Container || "mp4", bitrate: source.Bitrate ?? null, playUrl: buildPlayUrl(item.Id, label), }); } // De-dupe by resolution label and always offer an explicit "Original" (direct play) choice. const seen = new Set(options.map((o) => o.resolution)); if (!seen.has("Original")) { options.unshift({ resolution: "Original", container: "mp4", bitrate: null, playUrl: buildPlayUrl(item.Id, "Original"), }); } return options; } function buildPlayUrl(itemId: string, resolution: string): string { // Routed back through our own /api/jellyfin/stream proxy so the API key // never has to reach the browser. return `/api/jellyfin/stream/${itemId}?resolution=${encodeURIComponent(resolution)}`; } /** * Build the real, signed Jellyfin stream URL for a given item + resolution. * Called server-side only, by the stream proxy route. */ export function buildUpstreamStreamUrl(itemId: string, resolution: string): string { assertConfigured(); const params = new URLSearchParams({ api_key: JELLYFIN_API_KEY, Static: resolution === "Original" ? "true" : "false", }); if (resolution !== "Original") { const height = parseInt(resolution.replace("p", ""), 10); if (!Number.isNaN(height)) { params.set("maxHeight", String(height)); } } return `${JELLYFIN_URL}/Videos/${itemId}/stream.mp4?${params.toString()}`; }