import type { BaseMedia, ServiceHealth, SessionItem, SessionsResponse, SessionStreamMessage, SourceHealth, } from "../types/media"; import type { NowPlayingStatus, SourceMode, SourceStatus, StatusSnapshot } from "../types/status"; import { configuredSources, SESSION_FETCHERS, SessionFeed, SessionSource, } from "./sessionSources"; import { jellyfinSessionFeed } from "./jellyfinSocket"; import { plexSessionFeed } from "./plexSocket"; import { monitor } from "./monitor"; import { getPlexConfig, PlexAuthError, setupState } from "./plexConfig"; // Live upstream connections; a source without one is polled const FEEDS: Partial> = { plex: plexSessionFeed, jellyfin: jellyfinSessionFeed, }; const POLL_PLAYING_MS = 10_000; const POLL_IDLE_MS = 30_000; // A rejected token won't start working by itself, so check back rarely const POLL_UNAUTHORIZED_MS = 5 * 60_000; // A feed that hasn't delivered by then is backed up by polling const FEED_CONNECT_GRACE_MS = 5_000; // Upstream work stops this long after the last viewer leaves; a page reload // shouldn't tear everything down const IDLE_SHUTDOWN_MS = 2 * 60_000; // Coalesces near-simultaneous source updates into one push const BROADCAST_DELAY_MS = 100; // A manual refresh within this window of the last fetch reuses it const REFRESH_MIN_GAP_MS = 2_000; // Viewers interpolate playing progress, so a fresh offset within this much of // the interpolated one isn't worth pushing const PROGRESS_TOLERANCE_MS = 2_000; type Listener = (message: SessionStreamMessage) => void; type Item = SessionItem; type Category = keyof SessionsResponse; const CATEGORIES: Category[] = ["tracks", "movies", "episodes"]; const SOURCE_LABELS: Record = { plex: "Plex", jellyfin: "Jellyfin" }; interface SourceState { // What viewers were last sent, so change detection compares against what // they're actually interpolating from data: SessionsResponse | null; // When data's viewOffsets were current observedAt: number; error: Error | null; // Has completed at least one fetch since the hub started settled: boolean; fetching: boolean; lastFetch: number; failures: number; timer?: ReturnType; // The feed is connected and delivering, so polling is paused live: boolean; stopFeed?: () => void; // For the status page mode: SourceMode; modeSince: number; lastUpdate: number | null; } function hasPlaying(data: SessionsResponse | null): boolean { return !!data && CATEGORIES.some((c) => data[c].some((item) => item.state === "playing")); } function modeOf(state: SourceState): SourceMode { if (state.live) return "live"; if (state.error instanceof PlexAuthError) return "unauthorized"; if (state.error) return "error"; return state.settled ? "polling" : "connecting"; } // What viewers are told; "connecting" never reaches them, since nothing is // sent until every source has settled function healthOf(mode: SourceMode): SourceHealth { if (mode === "unauthorized") return "unauthorized"; return mode === "error" ? "unreachable" : "connected"; } // Upstream errors can echo request URLs, which may carry credentials function redact(message: string): string { let result = message; for (const secret of [getPlexConfig()?.token, process.env.PLEX_TOKEN, process.env.JELLYFIN_API_KEY]) { if (secret) result = result.split(secret).join("***"); } return result; } function nowPlaying(source: SessionSource, data: SessionsResponse): NowPlayingStatus[] { const entry = (item: Item, title: string): NowPlayingStatus => ({ source, user: item.userId, title, player: item.player, state: item.state, }); return [ ...data.tracks.map((t) => entry(t, `${t.artist} – ${t.title}`)), ...data.movies.map((m) => entry(m, m.year ? `${m.title} (${m.year})` : m.title)), ...data.episodes.map((e) => entry(e, `${e.showTitle} S${e.season}E${e.episode} – ${e.title}`)), ]; } function retryDelay(failures: number): number { return Math.min(5000 * Math.pow(1.5, failures - 1), 60_000); } function withoutOffset(item: Item): string { return JSON.stringify({ ...item, viewOffset: 0 }); } function isMeaningfulChange( prev: SessionsResponse, prevAt: number, next: SessionsResponse, nextAt: number ): boolean { return CATEGORIES.some((category) => { const prevItems: Item[] = prev[category]; const nextItems: Item[] = next[category]; if (prevItems.length !== nextItems.length) return true; const prevBySession = new Map(prevItems.map((item) => [item.sessionId, item])); return nextItems.some((item) => { const old = prevBySession.get(item.sessionId); if (!old || withoutOffset(old) !== withoutOffset(item)) return true; const oldOffset = old.viewOffset ?? 0; const offset = item.viewOffset ?? 0; if (item.state !== "playing") return offset !== oldOffset; const projected = oldOffset + (nextAt - prevAt); const expected = item.duration ? Math.min(projected, item.duration) : projected; return Math.abs(offset - expected) > PROGRESS_TOLERANCE_MS; }); }); } // Moves playing items' offsets forward to `now`, so a viewer joining between // fetches starts from the current position function advance(items: T[], elapsed: number): T[] { if (elapsed <= 0) return items; return items.map((item) => { if (item.state !== "playing") return item; const projected = (item.viewOffset ?? 0) + elapsed; return { ...item, viewOffset: item.duration ? Math.min(projected, item.duration) : projected }; }); } /** * Single owner of upstream session tracking. However many browsers are * watching, each media server has one live connection (or, failing that, one * polling loop) here, and every change is pushed to all subscribers. Upstream * work starts with the first subscriber and stops a while after the last one * leaves. */ class SessionHub { private listeners = new Set(); private sources = new Map(); private running = false; // Bumped on stop so fetches from a previous run are ignored private generation = 0; private shutdownTimer?: ReturnType; private stopsAt: number | null = null; private broadcastTimer?: ReturnType; subscribe(listener: Listener): () => void { this.listeners.add(listener); clearTimeout(this.shutdownTimer); this.stopsAt = null; if (!this.running) this.start(); else if (this.isReady()) listener(this.snapshot()); return () => { if (!this.listeners.delete(listener) || this.listeners.size > 0) return; clearTimeout(this.shutdownTimer); this.stopsAt = Date.now() + IDLE_SHUTDOWN_MS; this.shutdownTimer = setTimeout(() => this.stop(), IDLE_SHUTDOWN_MS); }; } /** Fetches every polled source now, unless one just did. Live feeds are already current. */ refresh(): void { if (!this.running) return; const now = Date.now(); for (const [source, state] of this.sources) { if (!state.live && !state.fetching && now - state.lastFetch > REFRESH_MIN_GAP_MS) { this.poll(source, this.generation); } } } /** * Picks up a source whose connection was added, changed or removed (e.g. by * signing in on /setup) without disturbing the others. */ reconfigure(source: SessionSource): void { if (!this.running) return; const old = this.sources.get(source); if (old) { clearTimeout(old.timer); old.stopFeed?.(); this.sources.delete(source); } if (configuredSources().includes(source)) this.startSource(source, this.generation); this.scheduleBroadcast(); } /** Upstream health and current sessions, for the owner's status page. */ getStatus(): Pick { const sources: SourceStatus[] = []; const playing: NowPlayingStatus[] = []; for (const [source, state] of this.sources) { const sessions = state.data ? nowPlaying(source, state.data) : []; playing.push(...sessions); sources.push({ source, mode: state.mode, since: state.modeSince, lastUpdate: state.lastUpdate, failures: state.failures, lastError: state.error ? redact(state.error.message) : null, sessions: sessions.length, }); } return { hub: { running: this.running, subscribers: this.listeners.size, stopsAt: this.stopsAt }, sources, nowPlaying: playing, }; } private start(): void { this.running = true; this.sources.clear(); const generation = this.generation; monitor.log("info", "Session hub started"); for (const source of configuredSources()) this.startSource(source, generation); // Nothing configured: viewers get an empty snapshot rather than waiting forever if (this.sources.size === 0) this.scheduleBroadcast(); } private startSource(source: SessionSource, generation: number): void { const state: SourceState = { data: null, observedAt: 0, error: null, settled: false, fetching: false, lastFetch: 0, failures: 0, live: false, mode: "connecting", modeSince: Date.now(), lastUpdate: null, }; this.sources.set(source, state); const feed = FEEDS[source]; // Node < 22 has no built-in WebSocket; those servers just poll if (!feed || typeof WebSocket === "undefined") { this.poll(source, generation); return; } try { state.stopFeed = feed({ onSessions: (data) => this.onFeedSessions(source, data, generation), onDisconnect: () => this.onFeedDisconnect(source, generation), }); state.timer = setTimeout(() => this.poll(source, generation), FEED_CONNECT_GRACE_MS); } catch (err) { console.error(`Could not start ${source} feed; polling instead:`, err); this.poll(source, generation); } } private stop(): void { monitor.log("info", "Session hub stopped (no viewers)"); this.running = false; this.stopsAt = null; this.generation++; clearTimeout(this.broadcastTimer); this.broadcastTimer = undefined; for (const state of this.sources.values()) { clearTimeout(state.timer); state.stopFeed?.(); } // Drop data too: offsets can't be projected across a gap with no fetches this.sources.clear(); } private onFeedSessions(source: SessionSource, data: SessionsResponse, generation: number): void { const state = this.sources.get(source); if (!state || generation !== this.generation) return; if (!state.live) console.info(`${source} sessions: live`); state.live = true; clearTimeout(state.timer); this.settle(state, this.ingest(state, data)); this.noteMode(source, state); } private onFeedDisconnect(source: SessionSource, generation: number): void { const state = this.sources.get(source); if (!state || generation !== this.generation || !state.live) return; console.warn(`${source} sessions: feed lost, polling until it reconnects`); state.live = false; this.noteMode(source, state); if (!state.fetching) this.poll(source, generation); } // Returns whether viewers need to hear about it private ingest(state: SourceState, data: SessionsResponse): boolean { let changed = state.error !== null; const observedAt = Date.now(); state.lastUpdate = observedAt; if (!state.data || isMeaningfulChange(state.data, state.observedAt, data, observedAt)) { state.data = data; state.observedAt = observedAt; changed = true; } state.error = null; state.failures = 0; return changed; } private settle(state: SourceState, changed: boolean): void { if (!state.settled) { state.settled = true; changed = true; } if (changed) this.scheduleBroadcast(); } private async poll(source: SessionSource, generation: number): Promise { const state = this.sources.get(source); if (!state || state.live) return; clearTimeout(state.timer); state.fetching = true; let changed = false; try { const data = await SESSION_FETCHERS[source](); // The feed took over mid-fetch and is at least as fresh, or the source // was reconfigured and this fetch used the old connection if (generation !== this.generation || state.live || this.sources.get(source) !== state) return; changed = this.ingest(state, data); } catch (err) { if (generation !== this.generation || state.live || this.sources.get(source) !== state) return; console.error(`Error fetching ${source} sessions:`, err); // A failed source contributes nothing rather than going stale changed = state.data !== null || state.error === null; state.data = null; state.error = err instanceof Error ? err : new Error(String(err)); state.failures++; } finally { if (generation === this.generation) { state.fetching = false; state.lastFetch = Date.now(); } } this.settle(state, changed); this.noteMode(source, state); const delay = state.error instanceof PlexAuthError ? POLL_UNAUTHORIZED_MS : state.error ? retryDelay(state.failures) : hasPlaying(state.data) ? POLL_PLAYING_MS : POLL_IDLE_MS; state.timer = setTimeout(() => this.poll(source, generation), delay); } private noteMode(source: SessionSource, state: SourceState): void { const mode = modeOf(state); if (mode === state.mode) return; // e.g. unreachable -> unauthorized changes no data but changes the banner if (healthOf(mode) !== healthOf(state.mode)) this.scheduleBroadcast(); const label = SOURCE_LABELS[source]; if (mode === "live") monitor.log("info", `${label}: live connection established`); else if (mode === "unauthorized") monitor.log("warn", `${label}: token rejected; sign in again to reconnect`); else if (mode === "error") { monitor.log("error", `${label}: unreachable — ${redact(state.error?.message ?? "unknown error")}`); } else if (state.mode === "live") monitor.log("warn", `${label}: live connection lost, polling`); else monitor.log("info", `${label}: polling`); state.mode = mode; state.modeSince = Date.now(); } private isReady(): boolean { for (const state of this.sources.values()) { if (!state.settled) return false; } return true; } private scheduleBroadcast(): void { if (this.broadcastTimer || !this.isReady()) return; this.broadcastTimer = setTimeout(() => { this.broadcastTimer = undefined; if (!this.running || !this.isReady()) return; const message = this.snapshot(); for (const listener of this.listeners) listener(message); }, BROADCAST_DELAY_MS); } private snapshot(): SessionStreamMessage { const now = Date.now(); const sessions: SessionsResponse = { tracks: [], movies: [], episodes: [] }; // Labels only: upstream error text can include server URLs and tokens const failed: string[] = []; const health: ServiceHealth = {}; for (const [source, state] of this.sources) { health[source] = healthOf(modeOf(state)); if (state.error) failed.push(SOURCE_LABELS[source]); if (!state.data) continue; const elapsed = now - state.observedAt; sessions.tracks.push(...advance(state.data.tracks, elapsed)); sessions.movies.push(...advance(state.data.movies, elapsed)); sessions.episodes.push(...advance(state.data.episodes, elapsed)); } const allFailed = this.sources.size > 0 && failed.length === this.sources.size; const message: SessionStreamMessage = { sessions, sources: health, setup: setupState() }; return allFailed ? { ...message, error: `Unable to reach ${failed.join(" or ")}.` } : message; } } // One hub per server process, surviving dev hot reloads and duplicated module // instances so there's never more than one upstream loop per media server const globalForHub = globalThis as typeof globalThis & { __sessionHub?: SessionHub }; export const sessionHub = (globalForHub.__sessionHub ??= new SessionHub());