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:
authored and GitHub committed 2026-09-04 18:46:10 +02:00
1 parent b2ca85172c
commit d8d36476e6
118 files changed
+3349 -352

No files matched your search

@@ -36,6 +36,7 @@ import type {
ElectronBridgeAppUpdateStatus,
ElectronBridgeDbOperationEvent,
ElectronBridgeDownloadStartPayload,
ElectronBridgeCurrentProgramsOptions,
ElectronBridgeEpgLookupOptions,
ElectronBridgeEpgProgress,
ElectronBridgePlaybackPositionInput,
@@ -662,7 +663,7 @@ const electronApi: ElectronBridgeApi = {
) => ipcRenderer.invoke('GET_CHANNEL_PROGRAMS', { channelId, options }),
getCurrentProgramsBatch: (
channelIds: string[],
options?: ElectronBridgeEpgLookupOptions
options?: ElectronBridgeCurrentProgramsOptions
) =>
ipcRenderer.invoke('GET_CURRENT_PROGRAMS_BATCH', {
channelIds,
@@ -42,6 +42,11 @@ function flattenSql(value: unknown, seen = new Set<unknown>()): string {
return sqlLike.value.join(' ');
}
// Bound parameters (`sql\`${now}\``) carry a scalar value.
if (typeof sqlLike.value === 'string') {
return sqlLike.value;
}
if (typeof sqlLike.name === 'string') {
return sqlLike.name;
}
@@ -289,6 +294,80 @@ describe('EpgQueryService', () => {
);
});
it('evaluates "currently airing" at the caller-supplied instant', async () => {
const whereCalls: unknown[] = [];
const select = jest
.fn()
.mockReturnValue(createLimitedSelectChain([], whereCalls));
getDatabase.mockResolvedValue({ select });
// The renderer hands over its wall clock shifted into the provider's
// EPG clock (display offset); the SQL must compare against exactly that.
const nowMs = Date.parse('2026-06-29T20:30:00.000Z');
await service.getCurrentProgramsBatch(['ch'], {
sourceUrls: ['https://example.com/guide.xml'],
nowMs,
});
const flattened = whereCalls.map((condition) => flattenSql(condition));
expect(
flattened.some((condition) =>
condition.includes('2026-06-29T20:30:00.000Z')
)
).toBe(true);
});
it('falls back to the main-process clock for a missing or corrupt instant', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-29T21:00:00.000Z'));
try {
for (const nowMs of [undefined, Number.NaN, 1e20, -1e20]) {
const whereCalls: unknown[] = [];
const select = jest.fn().mockReturnValue(
createLimitedSelectChain(
[
{
id: 1,
channelId: 'ch',
start: '2026-06-29T20:00:00Z',
stop: '2026-06-29T22:00:00Z',
title: 'Now',
description: null,
category: null,
iconUrl: null,
rating: null,
episodeNum: null,
sourceUrl: 'https://example.com/guide.xml',
},
],
whereCalls
)
);
getDatabase.mockResolvedValue({ select });
await expect(
service.getCurrentProgramsBatch(['ch'], {
sourceUrls: ['https://example.com/guide.xml'],
nowMs,
})
).resolves.toEqual({
ch: expect.objectContaining({ title: 'Now' }),
});
const flattened = whereCalls.map((condition) =>
flattenSql(condition)
);
expect(
flattened.some((condition) =>
condition.includes('2026-06-29T21:00:00.000Z')
)
).toBe(true);
}
} finally {
jest.useRealTimers();
}
});
it('falls back to an unscoped current-programme lookup when programmes are tagged with an out-of-scope source', async () => {
const whereCalls: unknown[] = [];
const currentProgram = {
@@ -165,7 +165,7 @@ export class EpgQueryService {
async getCurrentProgramsBatch(
channelIds: string[],
options: { sourceUrls?: string[] } = {}
options: { sourceUrls?: string[]; nowMs?: number } = {}
): Promise<Record<string, EpgProgram | null>> {
const result: Record<string, EpgProgram | null> = {};
if (!Array.isArray(channelIds) || channelIds.length === 0) {
@@ -185,7 +185,7 @@ export class EpgQueryService {
try {
const db = await getDatabase();
const now = new Date().toISOString();
const now = this.toAiringInstantIso(options.nowMs);
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
this.assignCurrentProgramRows(
@@ -618,6 +618,22 @@ export class EpgQueryService {
.limit(channelIds.length);
}
/**
* The instant "currently airing" is evaluated at, as the UTC ISO string
* `isAiringAt` expects. The renderer passes its wall clock shifted into
* the provider's EPG clock (the display offset setting) so the row it
* receives is the one it will also render as "now"; without a usable
* value the main process clock applies. Bounded to the range `Date` can
* serialize so a corrupt payload cannot throw inside the query.
*/
private toAiringInstantIso(nowMs?: number): string {
const usable =
typeof nowMs === 'number' &&
Number.isFinite(nowMs) &&
Math.abs(nowMs) <= 8.64e15;
return new Date(usable ? nowMs : Date.now()).toISOString();
}
/**
* Timezone-aware "currently airing at `now`" predicate. EPG start/stop are
* stored as ISO strings that often carry a UTC offset (e.g. `+03:00`) while
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron';
import {
ElectronBridgeCurrentProgramsOptions,
ElectronBridgeTrustOptions,
EpgChannelMetadata,
EpgProgram,
@@ -57,7 +58,7 @@ export default class EpgEvents {
_event,
args: {
channelIds: string[];
options?: { sourceUrls?: string[] };
options?: ElectronBridgeCurrentProgramsOptions;
}
) => {
return this.handleGetCurrentProgramsBatch(
@@ -219,7 +220,7 @@ export default class EpgEvents {
private static async handleGetCurrentProgramsBatch(
channelIds: string[],
options?: { sourceUrls?: string[] }
options?: ElectronBridgeCurrentProgramsOptions
): Promise<Record<string, EpgProgram | null>> {
return queryByResolvedChannelIds(channelIds, (resolvedIds) =>
epgQueryService.getCurrentProgramsBatch(resolvedIds, options)