Spaces:
No application file
No application file
| 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<string, string> { | |
| 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<void> { | |
| 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<JellyfinItemSummary | null> { | |
| 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<JellyfinItemSummary[]> { | |
| 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<JellyfinItemSummary | null> { | |
| 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()}`; | |
| } | |