import { MediaData, HistoryData, HistoryItem, HistoryEntry, BaseMedia, Episode, Movie, ServiceConfig, ServiceHealth, SessionItem, SessionsResponse, SetupState, SourceHealth, } from "../types/media"; import { fetchWithTimeout, isTimeoutError, getThumbnailUrl as getPlexThumbnailUrl } from "./plexApi"; import { jellyfinThumbnailUrl } from "./jellyfinApi"; import { MERGE_WINDOW_MS } from "./groupSessions"; let configPromise: Promise | null = null; // Shared by concurrent callers; a failure is not cached so the next poll // retries, and neither is "nothing connected", so a server connected on // /setup shows up without a reload function getServiceConfig(): Promise { configPromise ??= fetch("/api/config") .then((res) => res.ok ? (res.json() as Promise) : Promise.reject(new Error(`Config: ${res.status}`)) ) .then((config) => { if (!config.plex && !config.jellyfin) configPromise = null; return config; }) .catch((error) => { configPromise = null; throw error; }); return configPromise; } /** A source's sessions request failed; `health` says whether it was the sign-in. */ class SourceFetchError extends Error { constructor(message: string, readonly health: SourceHealth) { super(message); } } const SOURCE_LABELS: Record = { plex: "Plex", jellyfin: "Jellyfin", }; // Stamp with the client clock so progress interpolates from receipt time export function stampSessions(data: SessionsResponse): MediaData { const now = Date.now(); const stamp = >(item: T) => ({ ...item, syncedAt: now, startTime: new Date(now - (item.viewOffset || 0)).toISOString(), }); return { tracks: data.tracks.map(stamp), movies: data.movies.map(stamp), episodes: data.episodes.map(stamp), }; } async function fetchSessions( source: BaseMedia["source"], signal?: AbortSignal ): Promise { const label = SOURCE_LABELS[source]; try { const response = await fetchWithTimeout(`/api/${source}/sessions`, { headers: { Accept: "application/json" }, signal, }); if (!response.ok) { const errorText = await response.text(); let health: SourceHealth = "unreachable"; try { if (JSON.parse(errorText).reason === "unauthorized") health = "unauthorized"; } catch { // Not JSON, e.g. a proxy's error page } throw new SourceFetchError( `${label} API Error (${response.status}): ${errorText || "Unknown error"}`, health ); } const data: SessionsResponse = await response.json(); return stampSessions(data); } catch (error) { if (isTimeoutError(error)) { throw new Error(`Request timed out. The ${label} server may be unresponsive.`); } throw error; } } export interface PolledMediaData { data: MediaData; sources: ServiceHealth; setup: SetupState; } /** Thrown when every source failed; still says which ones and why. */ export class AllSourcesFailedError extends Error { constructor(cause: unknown, readonly sources: ServiceHealth, readonly setup: SetupState) { super(cause instanceof Error ? cause.message : String(cause)); } } export async function fetchMediaData(signal?: AbortSignal): Promise { const config = await getServiceConfig(); const configured = (["plex", "jellyfin"] as const).filter((source) => config[source]); if (configured.length === 0) { return { data: { tracks: [], movies: [], episodes: [] }, sources: {}, setup: config.setup }; } const results = await Promise.allSettled(configured.map((source) => fetchSessions(source, signal))); // A cancelled poll must surface as cancellation, not as partial data or a failure signal?.throwIfAborted(); const sources: ServiceHealth = {}; results.forEach((result, i) => { sources[configured[i]] = result.status === "fulfilled" ? "connected" : result.reason instanceof SourceFetchError ? result.reason.health : "unreachable"; }); if (results.every((r) => r.status === "rejected")) { throw new AllSourcesFailedError((results[0] as PromiseRejectedResult).reason, sources, config.setup); } const merged = results.reduce( (acc, result) => { if (result.status === "fulfilled") { acc.tracks.push(...result.value.tracks); acc.movies.push(...result.value.movies); acc.episodes.push(...result.value.episodes); } return acc; }, { tracks: [], movies: [], episodes: [] } as MediaData ); return { data: merged, sources, setup: config.setup }; } const HISTORY_MAX = 250; // Expects items sorted newest first function mergeSimultaneousPlays(items: HistoryItem[]): HistoryEntry[] { const entries: HistoryEntry[] = []; const latestByKey = new Map(); for (const { userName, ...item } of items) { const key = `${item.source}:${item.id}`; // viewedAt is unix seconds const window = (item.type === "track" ? MERGE_WINDOW_MS.track : MERGE_WINDOW_MS.video) / 1000; const entry = latestByKey.get(key); if (entry && entry.viewedAt - item.viewedAt <= window) { if (!entry.userNames.includes(userName)) entry.userNames.push(userName); entry.playCount++; continue; } const created: HistoryEntry = { ...item, userNames: [userName], playCount: 1 }; entries.push(created); latestByKey.set(key, created); } return entries; } export async function fetchHistory( signal?: AbortSignal, limit = 25 ): Promise { const clampedLimit = Math.min(limit, HISTORY_MAX); // Fetch one extra per source to detect whether more items exist const fetchLimit = clampedLimit + 1; const config = await getServiceConfig(); const fetches: Promise<{ items: HistoryItem[] }>[] = []; if (config.plex) { fetches.push( fetch(`/api/plex/history?limit=${fetchLimit}`, { signal }).then((r) => r.ok ? r.json() : Promise.reject(new Error(`Plex history: ${r.status}`)) ) ); } if (config.jellyfin) { fetches.push( fetch(`/api/jellyfin/history?limit=${fetchLimit}`, { signal }).then((r) => r.ok ? r.json() : Promise.reject(new Error(`Jellyfin history: ${r.status}`)) ) ); } const results = await Promise.allSettled(fetches); const perSource = results.map((result) => result.status === "fulfilled" ? result.value.items || [] : [] ); const allItems = perSource.flat(); allItems.sort((a, b) => b.viewedAt - a.viewedAt); // Merge before paging so a page holds `limit` rows. Merging can shrink the list // below the limit, so a source that filled its fetch also means more exist. const entries = mergeSimultaneousPlays(allItems); const sourceTruncated = perSource.some((items) => items.length >= fetchLimit); const hasMore = (entries.length > clampedLimit || sourceTruncated) && clampedLimit < HISTORY_MAX; return { items: entries.slice(0, clampedLimit), hasMore }; } export function getThumbnailUrl( item: Pick, options?: { quality?: "low" | "medium" | "high"; width?: number } ): string | null { if (!item.thumbnailFileId) return null; if (item.source === "jellyfin") { return jellyfinThumbnailUrl( item.thumbnailFileId, options?.quality || "medium", options?.width ); } // Plex return getPlexThumbnailUrl(item.thumbnailFileId, { quality: options?.quality || "low", width: options?.width, }); } export function getSeriesThumbnailUrl(episode: Pick): string | null { if (!episode.seriesThumbId) return null; if (episode.source === "jellyfin") { return jellyfinThumbnailUrl(episode.seriesThumbId, "high", 600); } return getPlexThumbnailUrl(episode.seriesThumbId, { quality: "high", width: 600 }); } export function getMovieBackdropUrl(movie: Pick): string | null { if (!movie.backdropPath) return null; if (movie.source === "jellyfin") { return jellyfinThumbnailUrl(movie.backdropPath, "high", 1280, "Backdrop"); } return getPlexThumbnailUrl(movie.backdropPath, { quality: "high", width: 1280 }); } export function getResponsiveThumbnailUrl( item: Pick, type: "music" | "movie" | "tv" ): string | null { if (!item.thumbnailFileId) return null; const sizes: Record = { music: { quality: "high", width: 500 }, movie: { quality: "high", width: 600 }, tv: { quality: "medium", width: 800 }, }; return getThumbnailUrl(item, sizes[type]); }