utils/sessionSources.ts
Rama main · réplica de lectura del repositorio original.
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<PlexSession[]> {
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<SessionsResponse> {
// 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<JellyfinItemDetail> {
const cacheKey = `jellyfin:item:${itemId}`;
const cached = serverCache.get<JellyfinItemDetail>(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<SessionsResponse> {
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<SessionsResponse> {
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<SessionSource, () => Promise<SessionsResponse>> = {
plex: fetchPlexSessions,
jellyfin: fetchJellyfinSessions,
};