All files / src/lib refresh-schedule.ts

100% Statements 42/42
100% Branches 19/19
100% Functions 11/11
100% Lines 40/40

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132            6x                                                   6x 41x     6x         93x 93x 93x 43x 43x       10x   43x         22x     92x       6x   6x     6x 19x     6x 7x 21x                                       6x 68x 68x   68x 49x 49x 45x 45x 34x     15x     15x 15x 15x 15x       15x       30x       34x 34x 34x     68x    
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import type { Provider } from "@/lib/types";
import { parseDateMs } from "@/lib/date";
 
const HOUR_MS = 60 * 60 * 1000;
 
/** The auto refresh part of a provider's settings. */
export interface RefreshSettings {
	autoRefresh: boolean;
	refreshIntervalHours: number;
	epgAutoRefresh: boolean;
	epgRefreshIntervalHours: number;
}
 
/**
 * Where the schedule reads each provider's settings and last EPG refresh.
 * The caller passes them in, since they live in hooks/useChannels, which
 * imports this module.
 */
export interface RefreshLookups {
	settingsFor: (providerId: string) => RefreshSettings;
	epgLastRefreshFor: (providerId: string) => number;
}
 
export interface DueRefreshes {
	playlistIds: string[];
	epgIds: string[];
}
 
/** A refresh is due once a full interval has passed since the last one. */
export const isRefreshDue = (lastMs: number, intervalHours: number, now: number): boolean =>
	now - lastMs >= intervalHours * HOUR_MS;
 
/** Which providers have auto refresh on and are due a playlist or EPG refresh. */
export const findDueRefreshes = (
	providers: Provider[],
	lookups: RefreshLookups,
	now: number = Date.now()
): DueRefreshes => {
	const playlistIds: string[] = [];
	const epgIds: string[] = [];
	for (const p of providers) {
		const settings = lookups.settingsFor(p.id);
		if (
			settings.autoRefresh &&
			isRefreshDue(parseDateMs(p.lastUpdated), settings.refreshIntervalHours, now)
		) {
			playlistIds.push(p.id);
		}
		if (
			settings.epgAutoRefresh &&
			p.epgUrl &&
			isRefreshDue(lookups.epgLastRefreshFor(p.id), settings.epgRefreshIntervalHours, now)
		) {
			epgIds.push(p.id);
		}
	}
	return { playlistIds, epgIds };
};
 
/** The first wait before trying a failed refresh again. */
export const RETRY_FIRST_DELAY_MS = 5 * 60 * 1000;
/** The longest wait between tries, so a source that comes back is picked up the same day. */
export const RETRY_MAX_DELAY_MS = 6 * HOUR_MS;
 
/** How long to wait after the given number of failures in a row, doubling each time. */
export const retryDelayMs = (failures: number): number =>
	Math.min(RETRY_FIRST_DELAY_MS * 2 ** Math.max(failures - 1, 0), RETRY_MAX_DELAY_MS);
 
/** Tracker keys for a provider's automatic playlist and guide refreshes. */
export const refreshKeys = {
	playlist: (providerId: string): string => `playlist:${providerId}`,
	epg: (providerId: string): string => `epg:${providerId}`,
};
 
export interface RefreshTracker {
	/**
	 * Starts the refresh for this key and returns its promise, which rejects if
	 * the refresh fails. If one is already running for the key, returns that
	 * one's promise instead of starting another. Returns null when the last try
	 * failed too recently to try again.
	 */
	run: (key: string, refresh: () => Promise<unknown>) => Promise<void> | null;
}
 
/**
 * Keeps automatic refreshes from piling up work. The splash screen and the
 * poll share one tracker, so a refresh that is still running when the poll
 * comes round isn't started a second time. A refresh that fails waits longer
 * before each new try instead of running every poll. For Xtream every try
 * sends the login, so a provider that's down would otherwise get one a minute.
 */
export const createRefreshTracker = (clock: () => number = Date.now): RefreshTracker => {
	const running = new Map<string, Promise<void>>();
	const failures = new Map<string, { count: number; retryAt: number }>();
 
	const run = (key: string, refresh: () => Promise<unknown>): Promise<void> | null => {
		const inFlight = running.get(key);
		if (inFlight) return inFlight;
		const failure = failures.get(key);
		if (failure && clock() < failure.retryAt) return null;
		const done = refresh()
			.then(
				() => {
					failures.delete(key);
				},
				(e: unknown) => {
					const count = (failure?.count ?? 0) + 1;
					const delay = retryDelayMs(count);
					failures.set(key, { count, retryAt: clock() + delay });
					console.error(
						`[refresh] ${key} failed, trying again in ${Math.round(delay / 60_000)} min:`,
						e
					);
					throw e;
				}
			)
			.finally(() => {
				running.delete(key);
			});
		// The poll doesn't wait for the result, so a failure it ignores must not
		// show up as an unhandled rejection. Callers that wait still see it.
		done.catch(() => {});
		running.set(key, done);
		return done;
	};
 
	return { run };
};