import type { BaseMedia, SessionsResponse } from "../types/media"; import { applyUsernameMap } from "./usernameMap"; import { serverCache, ITEM_DETAIL_CACHE_TTL } from "./serverCache"; import { fetchWithTimeout, mapPlexSessions, PlexSession } from "./plexApi"; import { getPlexConfig, PlexAuthError, plexHeaders } from "./plexConfig"; import { mapJellyfinSessions, EnrichedSession, JellyfinItemDetail, JellyfinSession, } from "./jellyfinApi"; export type SessionSource = BaseMedia["source"]; /** * A live upstream connection that pushes a source's full session list. While * it's disconnected, the hub covers the gap by polling. */ export interface SessionFeedHandlers { onSessions(data: SessionsResponse): void; onDisconnect(): void; } // Starts the feed; returns a function that stops it for good export type SessionFeed = (handlers: SessionFeedHandlers) => () => void; export function configuredSources(): SessionSource[] { const sources: SessionSource[] = []; if (getPlexConfig()) sources.push("plex"); if (process.env.JELLYFIN_URL && process.env.JELLYFIN_API_KEY) sources.push("jellyfin"); return sources; } // ── Plex ────────────────────────────────────────────────────────────────────── /** Raw /status/sessions metadata, with display names already mapped. */ export async function fetchRawPlexSessions(): Promise { const plex = getPlexConfig(); if (!plex) throw new Error("Plex configuration missing"); const response = await fetchWithTimeout(`${plex.url}/status/sessions`, { headers: plexHeaders(plex.token), }); if (response.status === 401) throw new PlexAuthError(); if (!response.ok) { throw new Error(`Plex API Error: ${response.status}`); } const raw = await response.json(); const sessions: PlexSession[] = raw?.MediaContainer?.Metadata || []; sessions.forEach((s) => { if (s.User?.title) s.User.title = applyUsernameMap(s.User.title); }); return sessions; } export async function fetchPlexSessions(): Promise { // Build the response field-by-field so nothing unlisted (addresses, file // paths, device ids) can reach the client return mapPlexSessions(await fetchRawPlexSessions()); } // ── Jellyfin ────────────────────────────────────────────────────────────────── export function jellyfinAuthHeader(apiKey: string): string { return `MediaBrowser Token="${apiKey}", Client="NowPlaying", Device="Server", DeviceId="now-playing-server", Version="1.0"`; } const DETAIL_FIELDS = "People,Genres,Studios,Overview,ProductionYear,OfficialRating,CommunityRating,Taglines"; // Item metadata rarely changes, so it's cached per item rather than re-fetched // with every session refresh. Failures aren't cached so the next refresh retries. async function fetchItemDetail( jellyfinUrl: string, apiKey: string, userId: string, itemId: string, ): Promise { const cacheKey = `jellyfin:item:${itemId}`; const cached = serverCache.get(cacheKey); if (cached) return { ...cached }; try { const res = await fetchWithTimeout( `${jellyfinUrl}/Users/${userId}/Items/${itemId}?fields=${DETAIL_FIELDS}`, { headers: { Authorization: jellyfinAuthHeader(apiKey) } }, ); if (!res.ok) { console.warn(`Jellyfin detail fetch failed for item ${itemId}: ${res.status}`); return {}; } const full: JellyfinItemDetail = await res.json(); // Keep only the typed fields; the raw item carries paths and media sources const detail: JellyfinItemDetail = { People: full.People, Genres: full.Genres, Studios: full.Studios, Overview: full.Overview, OfficialRating: full.OfficialRating, CommunityRating: full.CommunityRating, Taglines: full.Taglines, AlbumArtist: full.AlbumArtist, ProductionYear: full.ProductionYear, BackdropImageTags: full.BackdropImageTags, }; serverCache.set(cacheKey, detail, ITEM_DETAIL_CACHE_TTL); return { ...detail }; } catch (err) { console.warn(`Jellyfin detail fetch error for item ${itemId}:`, err); return {}; } } type ActiveJellyfinSession = EnrichedSession["session"]; export function activeJellyfinSessions(sessions: JellyfinSession[]): ActiveJellyfinSession[] { return sessions.filter( (s): s is ActiveJellyfinSession => !!s.IsActive && s.NowPlayingItem != null, ); } /** Adds cached item details (cast, genres, studio) and maps to the shared shape. */ export async function enrichJellyfinSessions( sessions: ActiveJellyfinSession[], ): Promise { const JELLYFIN_URL = process.env.JELLYFIN_URL; const JELLYFIN_API_KEY = process.env.JELLYFIN_API_KEY; if (!JELLYFIN_URL || !JELLYFIN_API_KEY) throw new Error("Jellyfin configuration missing"); const enriched: EnrichedSession[] = await Promise.all( sessions.map(async (session) => { const item = session.NowPlayingItem; const detail = await fetchItemDetail( JELLYFIN_URL, JELLYFIN_API_KEY, session.UserId, item.Id, ); if (item.Type === "Episode" && item.SeriesId) { const seriesDetail = await fetchItemDetail( JELLYFIN_URL, JELLYFIN_API_KEY, session.UserId, item.SeriesId, ); if (seriesDetail.People?.length) { detail.People = seriesDetail.People; } } return { session: { ...session, UserName: session.UserName && applyUsernameMap(session.UserName), }, detail, }; }), ); // Build the response field-by-field so nothing unlisted (endpoints, file // paths, device ids) can reach the client return mapJellyfinSessions(enriched); } export async function fetchJellyfinSessions(): Promise { const JELLYFIN_URL = process.env.JELLYFIN_URL; const JELLYFIN_API_KEY = process.env.JELLYFIN_API_KEY; if (!JELLYFIN_URL || !JELLYFIN_API_KEY) throw new Error("Jellyfin configuration missing"); const res = await fetchWithTimeout(`${JELLYFIN_URL}/Sessions`, { headers: { Authorization: jellyfinAuthHeader(JELLYFIN_API_KEY), Accept: "application/json", }, }); if (!res.ok) { throw new Error(`Jellyfin API Error: ${res.status}`); } const sessions: JellyfinSession[] = await res.json(); return enrichJellyfinSessions(activeJellyfinSessions(sessions)); } export const SESSION_FETCHERS: Record Promise> = { plex: fetchPlexSessions, jellyfin: fetchJellyfinSessions, };