Ctrl K
ComunidadGuías…

utils/sessionHub.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<Record<SessionSource, SessionFeed>> = {
  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<BaseMedia>;
type Category = keyof SessionsResponse;

const CATEGORIES: Category[] = ["tracks", "movies", "episodes"];

const SOURCE_LABELS: Record<SessionSource, string> = { 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<typeof setTimeout>;
  // 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<T extends Item>(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<Listener>();
  private sources = new Map<SessionSource, SourceState>();
  private running = false;
  // Bumped on stop so fetches from a previous run are ignored
  private generation = 0;
  private shutdownTimer?: ReturnType<typeof setTimeout>;
  private stopsAt: number | null = null;
  private broadcastTimer?: ReturnType<typeof setTimeout>;

  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<StatusSnapshot, "hub" | "sources" | "nowPlaying"> {
    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<void> {
    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());