mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
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>
141 lines
5.1 KiB
TypeScript
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);
|
|
}
|