Files
iptvnator/libs/shared/interfaces/src/lib/epg-display-offset.util.ts
T
d8d36476e6 feat(epg): add global EPG display time offset (#1489)
Adds a global EPG display-time offset (Settings → EPG, whole minutes, ±720) for guides whose provider labels programme times with the wrong timezone. Display-only: parsed XMLTV values, SQLite rows, catch-up URLs and recording snapshots keep the provider's own times, so changing it needs no guide refresh. Closes the global part of #50.

The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` with two equivalent forms: `epgDisplayTimeMs` shifts a programme for display, `epgProviderClockMs` shifts "now" into the provider's clock for every "currently airing" decision — the batched `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs`, and the channel lists, the Xtream/Stalker previews, the M3U player's current-programme mirror, the unified collection resolver, the dashboard live cards and the recording overlap all pick the same programme the guide renders as "now". Portal short-EPG windows start at the provider's own "now", so under a non-zero offset the Xtream preview surfaces cut their window from the full guide at the provider clock, Stalker short-EPG requests are widened for negative offsets, and every per-stream memory of the previous offset is retired together when the setting changes.

Co-authored-by: Mark Jardine <markjardine27@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:46:10 +02:00

141 lines
5.1 KiB
TypeScript

/**
* Global EPG display-time correction (`Settings.epgOffsetMinutes`).
*
* Some XMLTV and portal feeds label programme times with the wrong timezone.
* The offset corrects that on the DISPLAY side only: parsed XMLTV values,
* SQLite rows, catch-up URLs and recording snapshots keep the provider's own
* times, so changing the value never requires a guide refresh.
*
* The correction has exactly two equivalent forms, and every consumer must
* apply ONE of them per comparison — never both, or the shift doubles:
*
* - **Display form** — `epgDisplayTimeMs(rawMs, offset)`: shift a
* programme's timestamp by `+offset`, then compare it with wall-clock now
* or format it for the user. Used where programmes are rendered (timeline
* geometry, list rows, clock labels).
* - **Clock form** — `epgProviderClockMs(nowMs, offset)`: express wall-clock
* now in the provider's uncorrected clock (`now - offset`) and compare it
* with the raw programme times. Used where a "currently airing" decision is
* made over raw data that is handed on unchanged (the backend SQL lookup,
* store selectors, progress bars, recording windows).
*/
import type { EpgItem } from './epg-item.interface';
export const EPG_OFFSET_MIN_MINUTES = -720;
export const EPG_OFFSET_MAX_MINUTES = 720;
const MINUTE_MS = 60_000;
/**
* Clamps a persisted or user-entered offset to whole minutes within the
* supported range. Anything that is not a finite number (absent, `null`,
* a blank string, a boolean) collapses to `0`.
*/
export function normalizeEpgOffsetMinutes(value: unknown): number {
const offset =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: Number.NaN;
if (!Number.isFinite(offset)) {
return 0;
}
return Math.min(
EPG_OFFSET_MAX_MINUTES,
Math.max(EPG_OFFSET_MIN_MINUTES, Math.trunc(offset))
);
}
/** The normalized offset in milliseconds. */
export function epgOffsetMs(offsetMinutes: number): number {
return normalizeEpgOffsetMinutes(offsetMinutes) * MINUTE_MS;
}
/**
* Display form: a raw provider timestamp shifted into the user's corrected
* time. `NaN` passes through so callers keep their own validity checks.
*/
export function epgDisplayTimeMs(rawMs: number, offsetMinutes: number): number {
return rawMs + epgOffsetMs(offsetMinutes);
}
/**
* Clock form: a wall-clock instant expressed in the provider's uncorrected
* clock, for comparing against raw programme times. Equivalent to shifting
* every programme by `+offset` and comparing with `nowMs`.
*/
export function epgProviderClockMs(
nowMs: number,
offsetMinutes: number
): number {
return nowMs - epgOffsetMs(offsetMinutes);
}
/** Assumed minimum programme length when widening a short-EPG window. */
export const SHORT_EPG_SLOT_MINUTES = 15;
/** Upper bound for a widened short-EPG window. */
export const SHORT_EPG_WINDOW_MAX = 50;
/**
* Number of short-EPG entries to request so the window still reaches the
* programme on air under a display offset. Portal short-EPG endpoints
* (Xtream `get_short_epg`, Stalker `get_short_epg`) always start at the
* provider's own "now": under a negative offset the programme actually on
* air lies `|offset|` further ahead, so the window is widened assuming
* programmes of at least `SHORT_EPG_SLOT_MINUTES`. A positive offset needs
* the provider's past, which no window size can return — callers that have
* a full guide cut it with `windowEpgItemsAtProviderClock` instead.
*/
export function shortEpgWindowSize(
offsetMinutes: number,
baseSize: number
): number {
if (!(offsetMinutes < 0)) {
return baseSize;
}
return Math.min(
SHORT_EPG_WINDOW_MAX,
baseSize + Math.ceil(-offsetMinutes / SHORT_EPG_SLOT_MINUTES)
);
}
function epgItemSeconds(item: EpgItem, side: 'start' | 'stop'): number {
const raw = Number(
side === 'start' ? item.start_timestamp : item.stop_timestamp
);
if (Number.isFinite(raw) && raw > 0) {
return raw;
}
const parsed = Date.parse(
(side === 'start' ? item.start : (item.stop ?? item.end)) ?? ''
);
return Number.isFinite(parsed) ? Math.floor(parsed / 1000) : Number.NaN;
}
/**
* Cuts the same window a short-EPG request would return — the first `limit`
* programmes still airing or upcoming — out of a full guide, but at the
* provider clock (`epgProviderClockMs`) rather than at the provider's own
* "now". This is how preview surfaces stay correct under a display offset:
* the full guide reaches into the provider's past, the short EPG does not.
*/
export function windowEpgItemsAtProviderClock(
items: readonly EpgItem[],
offsetMinutes: number,
limit: number,
nowMs = Date.now()
): EpgItem[] {
const nowSeconds = Math.floor(
epgProviderClockMs(nowMs, offsetMinutes) / 1000
);
return [...items]
.sort(
(left, right) =>
epgItemSeconds(left, 'start') - epgItemSeconds(right, 'start')
)
.filter((item) => epgItemSeconds(item, 'stop') > nowSeconds)
.slice(0, limit);
}