mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user