mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
* perf(electron): let hidden and minimized windows report themselves hidden The main window was created with backgroundThrottling: false (since #1123, without a stated reason). Electron then keeps document.visibilityState at "visible" for a hidden, minimized or fully covered window and never lets Chromium throttle it, so every renderer timer, rAF and CSS transition ran at full rate in the background, and the playback keep-awake gate, which releases the display for a minimized window, could never see one. Use Chromium's default. Audible media and picture-in-picture are exempt from background throttling in Chromium, and a local check confirmed HLS playback continues unchanged through more than six minutes minimized, audible and muted. Playwright's focus emulation pins every page it attaches to as visible, so the new window-visibility E2E launches the app without Playwright and drives it over raw CDP (electron-unautomated-launch.ts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): harden the unautomated Electron launch Review follow-ups for the window-visibility E2E: - Resolve `electron` in the main process through a require created from the `node:module` builtin instead of `process.mainModule`, which only exists when the app entry is CommonJS. - Bound teardown like closeElectronApplicationAndConfirmExit: SIGTERM, then SIGKILL, 5 s each, then fail instead of waiting forever. - Surface CDP protocol errors from Runtime.evaluate instead of returning undefined. - Wait until the window is actually shown before hiding it. The app shows its window on ready-to-show, and a hide() that lands earlier is undone by that show(); this was the first-attempt failure on the macOS shard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): check the playback display lock is released while hidden Review follow-ups for #1724: - Add an E2E that plays the webm fixture in the unautomated launch, records the main process's prevent-display-sleep blockers, and asserts the keep-awake lock is taken while visible, released when the window is hidden, and taken again when it is shown. The visibility tests alone would still pass if the renderer gate or the bridge stopped updating powerSaveBlocker. - Validate CDP replies before dispatch (CodeQL js/unvalidated-dynamic-method-call): only a numeric id with a pending settle function is called. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): skip killing an Electron that already exited stopElectron now checks the recorded exit state before each signal and tolerates a kill that races the exit: on Windows taskkill throws for a PID that no longer exists. Startup cleanup can no longer replace the startup error that explains the failure; a cleanup failure there is logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(stalker): keep the watchdog cadence while the window is hidden With background throttling on, Chromium wakes a hidden, silent page's timers at most once per minute after five minutes, so a portal that asks for get_events every 30 s would see pings at half its cadence while the window is minimized. Tick the watchdog from a dedicated worker (createBackgroundInterval, an inline blob worker allowed by the renderer CSP), whose timers are not subject to page throttling. It falls back to a page setInterval where no worker is available or the worker fails to load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(stalker): keep a stopped watchdog interval stopped A worker error that arrived after stop() started the page fallback interval, which nothing cleared, so pings continued for an inactive playlist. stop() now marks the interval stopped, detaches the worker handlers, and the fallback refuses to start afterwards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
import {
|
|
isFullStalkerPortalPlaylist,
|
|
type Playlist,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import type { createLogger } from '@iptvnator/portal/shared/util';
|
|
import { createBackgroundInterval } from './background-interval';
|
|
|
|
/**
|
|
* The portal expects `get_events` every `watchdog_timeout` seconds — 120 by
|
|
* default — not every 25 s as the client historically pinged.
|
|
*/
|
|
export const STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS = 120;
|
|
|
|
/** Guard rails for a garbage `watchdog_timeout` echoed by a broken panel. */
|
|
const MIN_PERIOD_SECONDS = 30;
|
|
const MAX_PERIOD_SECONDS = 3600;
|
|
|
|
export interface StalkerWatchdogTiming {
|
|
periodSeconds: number;
|
|
/** Per-user jitter: delays the first periodic ping so clients spread out. */
|
|
timeslotSeconds: number;
|
|
}
|
|
|
|
export interface StalkerWatchdogDeps {
|
|
/** Sends one authenticated `get_events` ping. */
|
|
sendRequest: (
|
|
playlist: Playlist,
|
|
params: Record<string, string | number>
|
|
) => Promise<unknown>;
|
|
/**
|
|
* Reads the persisted row for a playlist. The row is the source of truth
|
|
* for the configuration a ping authenticates as; see `resolvePlaylist`.
|
|
*/
|
|
readPersistedPlaylist: (
|
|
playlistId: string
|
|
) => Promise<Playlist | undefined>;
|
|
logger: ReturnType<typeof createLogger>;
|
|
}
|
|
|
|
interface WatchdogTimers {
|
|
timeslotTimeout?: ReturnType<typeof setTimeout>;
|
|
/** Stops the periodic ping (see `createBackgroundInterval`). */
|
|
stopInterval?: () => void;
|
|
}
|
|
|
|
/**
|
|
* Keeps the active full portal's session "online" by pinging `get_events`.
|
|
*
|
|
* The cadence comes from the profile (`watchdog_timeout` + `timeslot`); until
|
|
* a profile has been decoded the documented default of 120 s applies. A missed
|
|
* ping never invalidates authentication — it only affects the portal's
|
|
* "online" reporting — so failures are logged and never retried or escalated.
|
|
*/
|
|
export class StalkerWatchdogController {
|
|
private readonly timers = new Map<string, WatchdogTimers>();
|
|
/** Activation-time snapshot; only a fallback when the row cannot be read. */
|
|
private readonly playlists = new Map<string, Playlist>();
|
|
private readonly inFlight = new Set<string>();
|
|
/** Survives stop/start: the cadence is a per-portal fact, not session state. */
|
|
private readonly timings = new Map<string, StalkerWatchdogTiming>();
|
|
private activePlaylistId: string | null = null;
|
|
private playlistDecorator: ((playlist: Playlist) => Playlist) | null = null;
|
|
|
|
constructor(private readonly deps: StalkerWatchdogDeps) {}
|
|
|
|
/**
|
|
* Sets which playlist should receive periodic watchdog pings.
|
|
* This keeps some Ministra/Stalker sessions alive for live playback.
|
|
*/
|
|
setActivePlaylist(playlist?: Playlist | null): void {
|
|
const nextPlaylistId = playlist?._id ?? null;
|
|
|
|
if (this.activePlaylistId && this.activePlaylistId !== nextPlaylistId) {
|
|
this.stop(this.activePlaylistId);
|
|
}
|
|
|
|
this.activePlaylistId = nextPlaylistId;
|
|
|
|
if (
|
|
!playlist ||
|
|
!isFullStalkerPortalPlaylist(playlist) ||
|
|
!playlist.portalUrl ||
|
|
!playlist.macAddress
|
|
) {
|
|
if (nextPlaylistId) {
|
|
this.stop(nextPlaylistId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.start(playlist);
|
|
}
|
|
|
|
/** Whether this playlist currently owns the watchdog. */
|
|
isActivePlaylist(playlistId: string): boolean {
|
|
return this.activePlaylistId === playlistId;
|
|
}
|
|
|
|
/**
|
|
* Lets the repair layer overlay its in-session override on the row a ping
|
|
* resolves: the persisted row can still carry a pre-repair configuration
|
|
* while persistence is pending (or failed), and pinging that would stop
|
|
* or misdirect the keepalive.
|
|
*/
|
|
registerPlaylistDecorator(
|
|
decorator: (playlist: Playlist) => Playlist
|
|
): void {
|
|
this.playlistDecorator = decorator;
|
|
}
|
|
|
|
/**
|
|
* Applies the cadence decoded from a `get_profile` response. When the
|
|
* playlist's watchdog is already running on a different cadence, its
|
|
* periodic schedule restarts (without re-sending the init ping).
|
|
*/
|
|
applyProfileTiming(
|
|
playlistId: string,
|
|
timing: { watchdogTimeoutSeconds?: number; timeslotSeconds?: number }
|
|
): void {
|
|
const next = normalizeTiming(timing);
|
|
const current = this.timings.get(playlistId);
|
|
if (
|
|
current &&
|
|
current.periodSeconds === next.periodSeconds &&
|
|
current.timeslotSeconds === next.timeslotSeconds
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.timings.set(playlistId, next);
|
|
|
|
if (this.timers.has(playlistId)) {
|
|
this.clearTimers(playlistId);
|
|
this.timers.set(playlistId, {});
|
|
this.schedule(playlistId);
|
|
}
|
|
}
|
|
|
|
private start(playlist: Playlist): void {
|
|
const playlistId = playlist._id;
|
|
this.playlists.set(playlistId, playlist);
|
|
|
|
if (this.timers.has(playlistId)) {
|
|
return;
|
|
}
|
|
|
|
this.timers.set(playlistId, {});
|
|
void this.sendPing(playlistId, '1');
|
|
this.schedule(playlistId);
|
|
}
|
|
|
|
private stop(playlistId: string): void {
|
|
this.clearTimers(playlistId);
|
|
this.timers.delete(playlistId);
|
|
this.playlists.delete(playlistId);
|
|
this.inFlight.delete(playlistId);
|
|
}
|
|
|
|
private clearTimers(playlistId: string): void {
|
|
const timers = this.timers.get(playlistId);
|
|
if (!timers) {
|
|
return;
|
|
}
|
|
if (timers.timeslotTimeout) {
|
|
clearTimeout(timers.timeslotTimeout);
|
|
}
|
|
if (timers.stopInterval) {
|
|
timers.stopInterval();
|
|
}
|
|
}
|
|
|
|
private timingFor(playlistId: string): StalkerWatchdogTiming {
|
|
return (
|
|
this.timings.get(playlistId) ?? {
|
|
periodSeconds: STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
|
|
timeslotSeconds: 0,
|
|
}
|
|
);
|
|
}
|
|
|
|
private schedule(playlistId: string): void {
|
|
const timers = this.timers.get(playlistId);
|
|
if (!timers) {
|
|
return;
|
|
}
|
|
|
|
const { periodSeconds, timeslotSeconds } = this.timingFor(playlistId);
|
|
const startInterval = () => {
|
|
const current = this.timers.get(playlistId);
|
|
if (!current) {
|
|
return;
|
|
}
|
|
current.timeslotTimeout = undefined;
|
|
// A worker-driven tick: a portal whose watchdog_timeout is below
|
|
// Chromium's one-minute wake-up limit for hidden pages must still
|
|
// see get_events on time while the window is minimized.
|
|
current.stopInterval = createBackgroundInterval(() => {
|
|
void this.sendPing(playlistId, '0');
|
|
}, periodSeconds * 1000);
|
|
};
|
|
|
|
if (timeslotSeconds > 0) {
|
|
timers.timeslotTimeout = setTimeout(
|
|
startInterval,
|
|
timeslotSeconds * 1000
|
|
);
|
|
} else {
|
|
startInterval();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The playlist a ping authenticates as. The persisted row is the source of
|
|
* truth: portal metadata (endpoint, mode, MAC, identity) edited or
|
|
* repaired mid-session must reach the keepalive within one cycle — the
|
|
* activation-time snapshot is only the fallback when the row cannot be
|
|
* read.
|
|
*/
|
|
private async resolvePlaylist(
|
|
playlistId: string
|
|
): Promise<Playlist | undefined> {
|
|
try {
|
|
const row = await this.deps.readPersistedPlaylist(playlistId);
|
|
if (row) {
|
|
// Keep the fallback snapshot fresh for the next cycle.
|
|
this.playlists.set(playlistId, row);
|
|
return this.decorate(row);
|
|
}
|
|
} catch {
|
|
// Store unavailable (e.g. isolated tests): keep the snapshot.
|
|
}
|
|
|
|
const snapshot = this.playlists.get(playlistId);
|
|
return snapshot ? this.decorate(snapshot) : snapshot;
|
|
}
|
|
|
|
private decorate(playlist: Playlist): Playlist {
|
|
return this.playlistDecorator
|
|
? this.playlistDecorator(playlist)
|
|
: playlist;
|
|
}
|
|
|
|
private async sendPing(playlistId: string, init: '0' | '1'): Promise<void> {
|
|
if (this.inFlight.has(playlistId)) {
|
|
return;
|
|
}
|
|
|
|
// Claimed BEFORE the row read: it awaits, and two overlapping pings
|
|
// passing the check together would double-fire the keepalive.
|
|
this.inFlight.add(playlistId);
|
|
try {
|
|
const playlist = await this.resolvePlaylist(playlistId);
|
|
if (
|
|
!playlist ||
|
|
!playlist.portalUrl ||
|
|
!playlist.macAddress ||
|
|
!isFullStalkerPortalPlaylist(playlist)
|
|
) {
|
|
this.stop(playlistId);
|
|
return;
|
|
}
|
|
|
|
await this.deps.sendRequest(playlist, {
|
|
type: 'watchdog',
|
|
action: 'get_events',
|
|
event_active_id: '0',
|
|
cur_play_type: '0',
|
|
init,
|
|
JsHttpRequest: '1-xml',
|
|
});
|
|
} catch (error) {
|
|
// Keep failures non-fatal; the next tick can recover after a
|
|
// token refresh. Never retry or escalate: a missed ping does not
|
|
// invalidate authentication.
|
|
this.deps.logger.warn('Watchdog ping failed:', error);
|
|
} finally {
|
|
this.inFlight.delete(playlistId);
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeTiming(timing: {
|
|
watchdogTimeoutSeconds?: number;
|
|
timeslotSeconds?: number;
|
|
}): StalkerWatchdogTiming {
|
|
const period = clamp(
|
|
Math.floor(
|
|
timing.watchdogTimeoutSeconds ??
|
|
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS
|
|
),
|
|
MIN_PERIOD_SECONDS,
|
|
MAX_PERIOD_SECONDS
|
|
);
|
|
const timeslot = clamp(
|
|
Math.floor(timing.timeslotSeconds ?? 0),
|
|
0,
|
|
period - 1
|
|
);
|
|
return { periodSeconds: period, timeslotSeconds: timeslot };
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
if (!Number.isFinite(value)) {
|
|
return min;
|
|
}
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|