mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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>
This commit is contained in:
118 files changed
+3349
-352
No files matched your search
@@ -10,6 +10,7 @@ export * from './lib/electron-api.interface';
|
||||
export * from './lib/epg-channel-metadata.model';
|
||||
export * from './lib/epg-channel-with-programs.interface';
|
||||
export * from './lib/epg-channel.model';
|
||||
export * from './lib/epg-display-offset.util';
|
||||
export * from './lib/epg-item.interface';
|
||||
export * from './lib/epg-mapping-key.util';
|
||||
export * from './lib/epg-program.model';
|
||||
|
||||
@@ -362,6 +362,19 @@ export interface ElectronBridgeEpgLookupOptions {
|
||||
sourceUrls?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the batched "currently airing" lookup. `nowMs` is the instant
|
||||
* the lookup is evaluated at: the renderer passes its wall clock shifted into
|
||||
* the provider's EPG clock (`epgProviderClockMs`, see
|
||||
* `epg-display-offset.util.ts`) so the row the backend picks is the one the
|
||||
* renderer will also present as "now". Absent, the main process uses its own
|
||||
* clock.
|
||||
*/
|
||||
export interface ElectronBridgeCurrentProgramsOptions
|
||||
extends ElectronBridgeEpgLookupOptions {
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeEpgProgressStats {
|
||||
totalChannels: number;
|
||||
totalPrograms: number;
|
||||
@@ -778,7 +791,7 @@ export interface ElectronBridgeApi {
|
||||
) => Promise<EpgProgram[]>;
|
||||
getCurrentProgramsBatch: (
|
||||
channelIds: string[],
|
||||
options?: ElectronBridgeEpgLookupOptions
|
||||
options?: ElectronBridgeCurrentProgramsOptions
|
||||
) => Promise<Record<string, EpgProgram | null>>;
|
||||
getEpgChannelMetadata: (
|
||||
channelIds: string[],
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { EpgItem } from './epg-item.interface';
|
||||
import {
|
||||
EPG_OFFSET_MAX_MINUTES,
|
||||
EPG_OFFSET_MIN_MINUTES,
|
||||
epgDisplayTimeMs,
|
||||
epgOffsetMs,
|
||||
epgProviderClockMs,
|
||||
normalizeEpgOffsetMinutes,
|
||||
shortEpgWindowSize,
|
||||
windowEpgItemsAtProviderClock,
|
||||
} from './epg-display-offset.util';
|
||||
|
||||
const NOW = Date.parse('2026-06-28T12:00:00.000Z');
|
||||
const HOUR_MS = 60 * 60_000;
|
||||
|
||||
describe('epg-display-offset.util', () => {
|
||||
describe('normalizeEpgOffsetMinutes', () => {
|
||||
it('keeps whole minutes inside the supported range', () => {
|
||||
expect(normalizeEpgOffsetMinutes(0)).toBe(0);
|
||||
expect(normalizeEpgOffsetMinutes(-60)).toBe(-60);
|
||||
expect(normalizeEpgOffsetMinutes(EPG_OFFSET_MAX_MINUTES)).toBe(720);
|
||||
expect(normalizeEpgOffsetMinutes(EPG_OFFSET_MIN_MINUTES)).toBe(
|
||||
-720
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps out-of-range values and truncates fractions', () => {
|
||||
expect(normalizeEpgOffsetMinutes(1000)).toBe(720);
|
||||
expect(normalizeEpgOffsetMinutes(-1000)).toBe(-720);
|
||||
expect(normalizeEpgOffsetMinutes(15.9)).toBe(15);
|
||||
expect(normalizeEpgOffsetMinutes(-15.9)).toBe(-15);
|
||||
});
|
||||
|
||||
it('accepts numeric strings from persisted or form values', () => {
|
||||
expect(normalizeEpgOffsetMinutes('90')).toBe(90);
|
||||
expect(normalizeEpgOffsetMinutes(' -30 ')).toBe(-30);
|
||||
});
|
||||
|
||||
it('collapses anything that is not a finite number to zero', () => {
|
||||
expect(normalizeEpgOffsetMinutes(undefined)).toBe(0);
|
||||
expect(normalizeEpgOffsetMinutes(null)).toBe(0);
|
||||
expect(normalizeEpgOffsetMinutes('')).toBe(0);
|
||||
expect(normalizeEpgOffsetMinutes('abc')).toBe(0);
|
||||
expect(normalizeEpgOffsetMinutes(Number.NaN)).toBe(0);
|
||||
expect(normalizeEpgOffsetMinutes(Number.POSITIVE_INFINITY)).toBe(0);
|
||||
expect(normalizeEpgOffsetMinutes(true)).toBe(0);
|
||||
expect(normalizeEpgOffsetMinutes({})).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('converts the normalized offset to milliseconds', () => {
|
||||
expect(epgOffsetMs(60)).toBe(HOUR_MS);
|
||||
expect(epgOffsetMs(5000)).toBe(720 * 60_000);
|
||||
expect(epgOffsetMs(Number.NaN)).toBe(0);
|
||||
});
|
||||
|
||||
it('shifts a raw timestamp into display time and now into the provider clock symmetrically', () => {
|
||||
const rawStart = NOW - HOUR_MS;
|
||||
// Display form: the programme moves by +offset ...
|
||||
expect(epgDisplayTimeMs(rawStart, 60)).toBe(NOW);
|
||||
// ... clock form: "now" moves by -offset. Both comparisons agree.
|
||||
expect(epgProviderClockMs(NOW, 60)).toBe(rawStart);
|
||||
expect(epgDisplayTimeMs(rawStart, 60) <= NOW).toBe(
|
||||
rawStart <= epgProviderClockMs(NOW, 60)
|
||||
);
|
||||
});
|
||||
|
||||
it('lets NaN pass through so callers keep their own validity checks', () => {
|
||||
expect(epgDisplayTimeMs(Number.NaN, 60)).toBeNaN();
|
||||
expect(epgProviderClockMs(Number.NaN, 60)).toBeNaN();
|
||||
});
|
||||
|
||||
describe('shortEpgWindowSize', () => {
|
||||
it('keeps the base window without an offset or with a positive one', () => {
|
||||
// The provider's past is unreachable through a short EPG, so a
|
||||
// positive offset cannot be served by a bigger window.
|
||||
expect(shortEpgWindowSize(0, 3)).toBe(3);
|
||||
expect(shortEpgWindowSize(120, 3)).toBe(3);
|
||||
expect(shortEpgWindowSize(0, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('widens the window under a negative offset in 15-minute slots and caps it', () => {
|
||||
expect(shortEpgWindowSize(-30, 3)).toBe(5);
|
||||
expect(shortEpgWindowSize(-100, 3)).toBe(10);
|
||||
expect(shortEpgWindowSize(-60, 10)).toBe(14);
|
||||
expect(shortEpgWindowSize(-720, 3)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('windowEpgItemsAtProviderClock', () => {
|
||||
const nowSeconds = Math.floor(NOW / 1000);
|
||||
const item = (
|
||||
title: string,
|
||||
startOffsetMin: number,
|
||||
durationMin: number,
|
||||
withTimestamps = true
|
||||
): EpgItem => {
|
||||
const start = nowSeconds + startOffsetMin * 60;
|
||||
const stop = start + durationMin * 60;
|
||||
return {
|
||||
id: title,
|
||||
epg_id: title,
|
||||
title,
|
||||
lang: 'en',
|
||||
description: '',
|
||||
channel_id: 'ch',
|
||||
start: new Date(start * 1000).toISOString(),
|
||||
end: new Date(stop * 1000).toISOString(),
|
||||
stop: new Date(stop * 1000).toISOString(),
|
||||
start_timestamp: withTimestamps ? String(start) : '',
|
||||
stop_timestamp: withTimestamps ? String(stop) : '',
|
||||
};
|
||||
};
|
||||
const guide = [
|
||||
item('Next', 15, 30),
|
||||
item('Provider now', -15, 30),
|
||||
item('Really on air', -75, 60),
|
||||
item('Long gone', -180, 60),
|
||||
item('Later', 45, 30),
|
||||
];
|
||||
|
||||
it('mirrors a short-EPG window at the provider clock', () => {
|
||||
// +60: the guide runs an hour ahead, so the window starts at the
|
||||
// programme the provider filed as finished 15 minutes ago.
|
||||
expect(
|
||||
windowEpgItemsAtProviderClock(guide, 60, 3, NOW).map(
|
||||
(entry) => entry.title
|
||||
)
|
||||
).toEqual(['Really on air', 'Provider now', 'Next']);
|
||||
// Without an offset it is the provider's own window.
|
||||
expect(
|
||||
windowEpgItemsAtProviderClock(guide, 0, 2, NOW).map(
|
||||
(entry) => entry.title
|
||||
)
|
||||
).toEqual(['Provider now', 'Next']);
|
||||
});
|
||||
|
||||
it('falls back to the ISO fields when unix timestamps are absent', () => {
|
||||
const isoOnly = guide.map((entry) => ({
|
||||
...entry,
|
||||
start_timestamp: '',
|
||||
stop_timestamp: '',
|
||||
}));
|
||||
expect(
|
||||
windowEpgItemsAtProviderClock(isoOnly, -30, 2, NOW).map(
|
||||
(entry) => entry.title
|
||||
)
|
||||
).toEqual(['Next', 'Later']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -44,6 +44,28 @@ describe('filterRecordingProgramsOverlap', () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('moves the recorded window into the provider clock by the display offset', () => {
|
||||
// Recorded 22:00–22:30 wall-clock. With the guide running an hour
|
||||
// ahead (+60), that window is 21:00–21:30 in the provider's clock and
|
||||
// covers "News" instead of "Weather" / "Late show".
|
||||
expect(
|
||||
filterRecordingProgramsOverlap(
|
||||
programs,
|
||||
'2026-08-15T22:00:00Z',
|
||||
'2026-08-15T22:30:00Z'
|
||||
).map((program) => program.title)
|
||||
).toEqual(['Weather', 'Late show']);
|
||||
const shifted = filterRecordingProgramsOverlap(
|
||||
programs,
|
||||
'2026-08-15T22:00:00Z',
|
||||
'2026-08-15T22:30:00Z',
|
||||
60
|
||||
);
|
||||
expect(shifted.map((program) => program.title)).toEqual(['News']);
|
||||
// The snapshots themselves keep the provider's raw times.
|
||||
expect(shifted[0].start).toBe('2026-08-15T21:00:00Z');
|
||||
});
|
||||
|
||||
it('keeps every overlapping program sorted by start (boundary case)', () => {
|
||||
const result = filterRecordingProgramsOverlap(
|
||||
programs,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { epgProviderClockMs } from './epg-display-offset.util';
|
||||
import type { RecordingProgramSnapshot } from './recording-metadata.interface';
|
||||
|
||||
/**
|
||||
@@ -26,17 +27,26 @@ export function toRecordingProgramSnapshot(program: {
|
||||
* handlers, so a recording spanning a program boundary reports every covered
|
||||
* program. Fails closed: an unknown recording start yields an empty list
|
||||
* (the start snapshot then stands).
|
||||
*
|
||||
* The recorded window is wall-clock while the snapshots keep the provider's
|
||||
* raw times, so the window is moved into the provider's EPG clock by the
|
||||
* display offset (`epg-display-offset.util.ts`, clock form) before the
|
||||
* overlap test; the snapshots themselves stay unshifted.
|
||||
*/
|
||||
export function filterRecordingProgramsOverlap(
|
||||
programs: readonly RecordingProgramSnapshot[],
|
||||
startedAt: string | null,
|
||||
endedAt: string
|
||||
endedAt: string,
|
||||
offsetMinutes = 0
|
||||
): RecordingProgramSnapshot[] {
|
||||
if (!startedAt) {
|
||||
return [];
|
||||
}
|
||||
const windowStart = Date.parse(startedAt);
|
||||
const windowEnd = Date.parse(endedAt);
|
||||
const windowStart = epgProviderClockMs(
|
||||
Date.parse(startedAt),
|
||||
offsetMinutes
|
||||
);
|
||||
const windowEnd = epgProviderClockMs(Date.parse(endedAt), offsetMinutes);
|
||||
if (
|
||||
!Number.isFinite(windowStart) ||
|
||||
!Number.isFinite(windowEnd) ||
|
||||
|
||||
@@ -233,6 +233,14 @@ export interface Settings {
|
||||
coverSize?: CoverSize;
|
||||
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
|
||||
epgViewMode?: EpgViewMode;
|
||||
/**
|
||||
* Display-only correction, in whole minutes, for guides whose provider
|
||||
* labels programme times with the wrong timezone. Clamped to ±720 by
|
||||
* `normalizeEpgOffsetMinutes`; the two equivalent ways of applying it are
|
||||
* documented in `epg-display-offset.util.ts`. Electron only in practice
|
||||
* (the control is gated behind `supportsEpg`, like `epgViewMode`).
|
||||
*/
|
||||
epgOffsetMinutes?: number;
|
||||
/** Per-rail dashboard visibility preferences. Missing keys default on. */
|
||||
dashboardRails?: DashboardRailsSettings;
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user