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>
152 lines
6.0 KiB
TypeScript
152 lines
6.0 KiB
TypeScript
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']);
|
|
});
|
|
});
|
|
});
|