Jerryo / src /server /jellyfinClient.ts
EricaLuvGemma's picture
Upload 6 files
972b772 verified
Raw
History Blame Contribute Delete
6.51 kB
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()}`;
}