mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(xtream): render catch-up start times in the panel timezone (#1563)
* fix(xtream): render catch-up start times in the panel timezone
The `{Y-m-d:H-M}` segment of an Xtream timeshift URL is read by the panel
with `strtotime()` in ITS timezone (`server_info.timezone`), never the
viewer's. The timezone was learned in memory only, by the store's
`checkPortalStatus()`, so the Favorites / Recent catch-up resolver — which
reads the STORED playlist row — always fell back to the viewer's local
clock and asked the panel for the wrong programme (#1562).
- Normalize the panel's clock once (`resolveXtreamServerTimezone`): an
ICU-resolvable name is kept, otherwise a `UTC±HH:MM` offset is derived
from the `time_now` / `timestamp_now` clock pair, so spellings such as
`UTC+3` no longer silently mean "local time".
- Persist it on the playlist row through `transformPlaylistMeta` (no-op
when unchanged) and project it back from the payload in
`DB_GET_PLAYLIST`, so both catch-up entry points and a restart see it.
- Format with `hourCycle: 'h23'` (server midnight is `00`, never `24`) and
read timestamp-less EPG `start`/`end` strings in the panel's clock.
- Mock: `tzoffset:tzoffset` scenario with an unusable timezone name and a
+03:00 clock pair; Electron e2e covers Live TV, Favorites, a restart into
Global favorites, and the clock-pair derivation at a UTC-3 viewer.
Closes #1562
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): guard the account-info answer by playlist identity and reject rolled-over dates
Review follow-ups (Greptile):
- A source switch while `get_account_info` is in flight no longer hands
playlist A's status or clock to playlist B: the store is patched only
while the asking playlist is still selected, the timezone is persisted
under the asking playlist's id regardless, and a late failure cannot mark
the newly selected playlist unavailable.
- `parseNaiveUtcMs` reads the constructed date back, so out-of-range panel
strings (`2026-13-01 25:00:00`) are rejected instead of silently rolling
over into a real instant.
- Document that a clock-derived fixed offset is a DST-less snapshot, refreshed
by every account-info check and only ever used for non-standard servers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): drop a panel clock that no longer belongs to the source
Review follow-ups (Codex + Greptile):
- A metadata update or DB_UPDATE_PLAYLIST that points the source at another
server drops the persisted `serverTimezone` (payload-only) until the next
account-info check, so Favorites / Recent cannot keep rendering the OLD
panel's clock; an update that supplies a clock keeps it.
- A late account-info answer is persisted only onto a row that still points
at the panel it came from — an edit that moved the source during the
request keeps the clock the edit flow dropped.
- The PWA data source and the route-session converter carry the persisted
timezone into the store playlist, so a later response without a usable
clock has a previous value to preserve.
- Mirror the catch-up timezone contract into AGENTS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): drop the stale panel clock inside the UPDATE statement
Review follow-up (Codex): the database worker interleaves requests, so a
read-modify-write of the playlist payload could hand a concurrent upsert's
newer payload back to the past. The `serverTimezone` removal on a server
URL change is now one `CASE … json_remove(payload, '$.serverTimezone')`
expression inside the same UPDATE, guarded by `json_valid`; the spec runs
the real statement against Electron's SQLite on the actual `playlists`
table (moved, renamed, clock-less, malformed-payload and NULL-URL rows).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(xtream): split the server-clock primitives out of the timezone util
Review follow-up (Greptile): `xtream-server-timezone.util.ts` had grown past
the 300-line file guideline. The zone-agnostic wall-clock primitives (stored
forms, Intl parts, naive parsing) now live in `xtream-server-clock.util.ts`;
the timezone util keeps the Xtream policy and re-exports the public helpers,
so every import and the spec are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): offer the learned panel clock to storage on every check
Review follow-up (Codex): a transient storage failure left the clock in the
store but not on the row, and the next check compared the answer with the
in-memory value and never retried. The resolved timezone is now always
handed to `transformPlaylistMeta`, whose row-level equality check keeps the
common case a read without a write; a failed write is retried by the next
account-info check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): apply an account-info answer only to the panel it came from
Review follow-up (Codex): an in-place edit keeps the playlist id while
moving the source, so an answer already on the wire for the OLD panel
passed the id-only guard and patched the new panel's status and clock into
the store. One `answersFor(candidate, credentials)` predicate now gates the
store patch, the error path and the persisted-row transform alike.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): never report another panel's status for the selected playlist
Review follow-up (Greptile): callers gate content initialization on the
value `checkPortalStatus()` returns for whatever is selected NOW. When the
answer no longer describes the selected playlist (source switch or in-place
edit during the request), the store's own verdict about the current
selection is returned instead of the old panel's status — on success and on
failure alike.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): persist the panel clock with one conditional UPDATE
Review follow-up (Codex): `transformPlaylistMeta` reads the row and then
upserts it whole, while the Xtream edit dialog saves through
`DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue and the database
worker interleaves requests — an edit landing between that read and the
upsert was silently undone.
Persistence now goes through `IXtreamDataSource.rememberServerTimezone`:
- Electron: new `DB_SET_PLAYLIST_SERVER_TIMEZONE` worker op — one UPDATE
that `json_set`s the payload only while the row still points at the
request's connection and does not already carry the value; a malformed
payload is never rewritten (CASE, not AND, so json_extract cannot run
before json_valid). Wired through the worker types, main handler,
preload, bridge interface, both IPC contract tables and
`DatabaseService.setXtreamPlaylistServerTimezone`.
- PWA: `transformPlaylistMeta`, whose read and write share one IndexedDB
readwrite cursor transaction, plus the localStorage copy.
The store no longer injects `PlaylistsService`; it offers the resolved clock
to the data source and keeps only its in-memory guards. Real-SQLite coverage
for the op (fresh / same / moved / NULL / malformed / missing rows),
delegation specs for both data sources, docs updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): keep the stored panel clock across clockless full upserts
Review follow-up (Codex): a `PlaylistsService` mutation that read the row
before `DB_SET_PLAYLIST_SERVER_TIMEZONE` landed and upserted afterwards
replaced the payload with its clockless snapshot. `DB_UPSERT_APP_PLAYLIST(S)`
now carry the STORED clock into a snapshot that has none while the row still
points at the same connection (`playlistConflictUpdate`, nested CASE so the
json_* readers never run on a malformed payload); a snapshot with its own
clock, or one that moves the source, wins as is. Real-SQLite coverage for
kept / moved / own-clock / batch rows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(release): split capture-navigation under the max-lines cap
`tools/release/capture-navigation.ts` had grown to 567 counted lines, past
the 400-line rule, which failed `release-tools:lint` and — because the file
was not in the baseline — the max-lines baseline test on master and on
every PR branched from it. The 19 named setup actions are now grouped by
subject over one leaf module of shared page helpers:
- `capture-navigation-helpers.ts`: playlist-id registry, dialog handling,
navigation moves, `settleUi`
- `capture-navigation-setup-actions.ts`: add-playlist dialogs, settings
sections, remote control
- `capture-navigation-portal-actions.ts`: portal catalogs, live lists,
alternative sources (the two identical live-category flows share one
helper)
- `capture-navigation-download-actions.ts`: the download manager shots
- `capture-navigation.ts`: the `runAction` dispatcher, theme switching and
the re-exported API the seeding driver and the capture script import
Actions call their siblings directly instead of recursing through
`runAction`, so no module depends on the dispatcher. The action vocabulary
is unchanged (same 19 names, same waits and timeouts); every file is under
300 lines and the new modules are listed in the `release-tools` lint target.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(electron): move the panel-clock SQL into its own operations module
Review follow-up (Greptile): the timezone persistence, invalidation,
upsert-preservation and row projection had landed in
`playlist.operations.ts`, a baselined 1,000-line file. They now live in
`playlist-server-timezone.operations.ts` (155 lines) — the three SQL
shapes plus the payload projection — and the playlist operations compose
them; the baselined file shrinks by 107 lines. Behaviour and the
real-SQLite coverage are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
98da686cea
commit
97b0264dee
51 files changed
+3259
-844
No files matched your search
@@ -70,6 +70,7 @@ export * from './lib/xtream-live-stream.interface';
|
||||
export * from './lib/xtream-response.interface';
|
||||
export * from './lib/xtream-portal.utils';
|
||||
export * from './lib/xtream-restore-state.util';
|
||||
export * from './lib/xtream-server-timezone.util';
|
||||
export * from './lib/xtream-recently-added.utils';
|
||||
export * from './lib/xtream-performance-marker.interface';
|
||||
export * from './lib/xtream-serie-details.interface';
|
||||
|
||||
@@ -896,6 +896,16 @@ export interface ElectronBridgeApi {
|
||||
dbGetPlaylist: (
|
||||
playlistId: string
|
||||
) => Promise<ElectronBridgePlaylistRow | null>;
|
||||
/**
|
||||
* Records the panel clock learned from account info as one conditional
|
||||
* UPDATE on the row payload; a no-op unless the row still points at the
|
||||
* given connection (issue #1562).
|
||||
*/
|
||||
dbSetPlaylistServerTimezone: (
|
||||
playlistId: string,
|
||||
connection: { serverUrl: string; username: string; password: string },
|
||||
serverTimezone: string
|
||||
) => Promise<{ updated: boolean }>;
|
||||
dbUpsertAppPlaylist: (
|
||||
playlist: Playlist,
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,7 @@ export type PlaylistMeta = Pick<
|
||||
| 'hiddenGroupTitles'
|
||||
| 'portalUrl'
|
||||
| 'recentlyViewed'
|
||||
| 'serverTimezone'
|
||||
| 'isFullStalkerPortal'
|
||||
| 'stalkerSerialNumber'
|
||||
| 'stalkerDeviceId1'
|
||||
|
||||
@@ -54,7 +54,14 @@ export interface Playlist {
|
||||
recentlyViewed?: PlaylistRecentlyViewedItem[];
|
||||
/** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */
|
||||
isFullStalkerPortal?: boolean;
|
||||
/** Xtream server timezone string for catch-up URL construction */
|
||||
/**
|
||||
* The Xtream panel's own timezone, learned from `server_info` of the
|
||||
* account-info response and normalized by `resolveXtreamServerTimezone`
|
||||
* (an IANA name, or a clock-derived `UTC±HH:MM`). Persisted on the row so
|
||||
* every catch-up URL builder — the Live TV layout AND the Favorites /
|
||||
* Recent resolver, which reads the stored row — renders programme start
|
||||
* times in the clock the panel's `strtotime()` expects (issue #1562).
|
||||
*/
|
||||
serverTimezone?: string;
|
||||
/** Session token for full stalker portal authentication - persisted for session */
|
||||
stalkerToken?: string;
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Wall-clock primitives behind `xtream-server-timezone.util.ts`: the two
|
||||
* stored timezone forms (an ICU-resolvable name, or a fixed `UTC±HH:MM`
|
||||
* snapshot), and the conversions between an instant and the wall clock a
|
||||
* zone shows for it. Nothing here knows about Xtream — that policy lives in
|
||||
* the timezone util.
|
||||
*/
|
||||
|
||||
/** Stored form for a panel whose timezone name is unusable: `UTC`, `UTC+03:00`, `UTC-03:30`. */
|
||||
const FIXED_OFFSET_TIMEZONE_PATTERN = /^UTC(?:([+-])(\d{2}):(\d{2}))?$/;
|
||||
const SERVER_LOCAL_DATE_TIME_PATTERN =
|
||||
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/;
|
||||
export const MINUTE_MS = 60_000;
|
||||
|
||||
const timeZoneSupportCache = new Map<string, boolean>();
|
||||
|
||||
export interface WallClockParts {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the runtime can format dates in the named zone. `Intl` accepts
|
||||
* IANA names and a few aliases (`UTC`, `GMT`, `Etc/GMT+3`) and throws a
|
||||
* `RangeError` for anything else (`UTC+3`, an empty string, garbage).
|
||||
*/
|
||||
export function isSupportedTimeZoneName(name: string): boolean {
|
||||
const cached = timeZoneSupportCache.get(name);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
let supported = false;
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: name });
|
||||
supported = true;
|
||||
} catch {
|
||||
supported = false;
|
||||
}
|
||||
timeZoneSupportCache.set(name, supported);
|
||||
return supported;
|
||||
}
|
||||
|
||||
/** Parses the stored `UTC±HH:MM` form; `null` for anything else. */
|
||||
export function parseFixedOffsetTimeZone(value: string): number | null {
|
||||
const match = FIXED_OFFSET_TIMEZONE_PATTERN.exec(value);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
if (!match[1]) {
|
||||
return 0;
|
||||
}
|
||||
const minutes = Number(match[2]) * 60 + Number(match[3]);
|
||||
return match[1] === '-' ? -minutes : minutes;
|
||||
}
|
||||
|
||||
export function formatFixedOffsetTimeZone(offsetMinutes: number): string {
|
||||
if (offsetMinutes === 0) {
|
||||
return 'UTC';
|
||||
}
|
||||
const sign = offsetMinutes < 0 ? '-' : '+';
|
||||
const absolute = Math.abs(offsetMinutes);
|
||||
return `UTC${sign}${padTwo(Math.floor(absolute / 60))}:${padTwo(absolute % 60)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wall clock a zone shows for an instant. Accepts both stored forms;
|
||||
* with no usable zone the viewer's local clock is the only remaining guess.
|
||||
*/
|
||||
export function wallClockPartsAt(
|
||||
instantMs: number,
|
||||
timeZone: string | null | undefined
|
||||
): WallClockParts {
|
||||
if (timeZone) {
|
||||
const fixedOffset = parseFixedOffsetTimeZone(timeZone);
|
||||
if (fixedOffset !== null) {
|
||||
return utcParts(new Date(instantMs + fixedOffset * MINUTE_MS));
|
||||
}
|
||||
if (isSupportedTimeZoneName(timeZone)) {
|
||||
return zoneParts(instantMs, timeZone);
|
||||
}
|
||||
}
|
||||
const date = new Date(instantMs);
|
||||
return {
|
||||
year: date.getFullYear(),
|
||||
month: date.getMonth() + 1,
|
||||
day: date.getDate(),
|
||||
hour: date.getHours(),
|
||||
minute: date.getMinutes(),
|
||||
second: date.getSeconds(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The named zone's UTC offset, in minutes, at a given instant. */
|
||||
export function zoneOffsetMinutesAt(
|
||||
instantMs: number,
|
||||
timeZone: string
|
||||
): number {
|
||||
const parts = zoneParts(instantMs, timeZone);
|
||||
const asUtcMs = Date.UTC(
|
||||
parts.year,
|
||||
parts.month - 1,
|
||||
parts.day,
|
||||
parts.hour,
|
||||
parts.minute,
|
||||
parts.second
|
||||
);
|
||||
return Math.round((asUtcMs - instantMs) / MINUTE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* `YYYY-MM-DD HH:mm[:ss]` read as if it were UTC; `null` when malformed.
|
||||
* `Date.UTC` rolls out-of-range fields over (`2026-13-01 25:00` becomes a
|
||||
* real instant in 2027), so only a string that reads back unchanged counts.
|
||||
*/
|
||||
export function parseNaiveUtcMs(
|
||||
value: string | null | undefined
|
||||
): number | null {
|
||||
const match = SERVER_LOCAL_DATE_TIME_PATTERN.exec(
|
||||
String(value ?? '').trim()
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [, year, month, day, hour, minute, second = '0'] = match;
|
||||
const fields = [
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second),
|
||||
];
|
||||
const ms = Date.UTC(
|
||||
fields[0],
|
||||
fields[1],
|
||||
fields[2],
|
||||
fields[3],
|
||||
fields[4],
|
||||
fields[5]
|
||||
);
|
||||
if (!Number.isFinite(ms)) {
|
||||
return null;
|
||||
}
|
||||
const readBack = utcParts(new Date(ms));
|
||||
const unchanged = [
|
||||
readBack.year,
|
||||
readBack.month - 1,
|
||||
readBack.day,
|
||||
readBack.hour,
|
||||
readBack.minute,
|
||||
readBack.second,
|
||||
].every((field, index) => field === fields[index]);
|
||||
return unchanged ? ms : null;
|
||||
}
|
||||
|
||||
export function padTwo(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function zoneParts(instantMs: number, timeZone: string): WallClockParts {
|
||||
// `hourCycle: 'h23'` — the `hour12: false` spelling still yields "24"
|
||||
// for midnight in some ICU/locale combinations. The option is typed
|
||||
// from es2020.intl while the web app compiles against an es2018 lib,
|
||||
// hence the assertion; every supported runtime honours it.
|
||||
const options = {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
hourCycle: 'h23',
|
||||
} as Intl.DateTimeFormatOptions;
|
||||
const parts = new Intl.DateTimeFormat('en-US', options).formatToParts(
|
||||
new Date(instantMs)
|
||||
);
|
||||
const read = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((part) => part.type === type)?.value ?? 0);
|
||||
return {
|
||||
year: read('year'),
|
||||
month: read('month'),
|
||||
day: read('day'),
|
||||
hour: read('hour') % 24,
|
||||
minute: read('minute'),
|
||||
second: read('second'),
|
||||
};
|
||||
}
|
||||
|
||||
function utcParts(date: Date): WallClockParts {
|
||||
return {
|
||||
year: date.getUTCFullYear(),
|
||||
month: date.getUTCMonth() + 1,
|
||||
day: date.getUTCDate(),
|
||||
hour: date.getUTCHours(),
|
||||
minute: date.getUTCMinutes(),
|
||||
second: date.getUTCSeconds(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
deriveXtreamServerUtcOffsetMinutes,
|
||||
formatFixedOffsetTimeZone,
|
||||
formatXtreamCatchupStart,
|
||||
isSupportedTimeZoneName,
|
||||
parseFixedOffsetTimeZone,
|
||||
parseXtreamServerLocalDateTime,
|
||||
resolveXtreamServerTimezone,
|
||||
} from './xtream-server-timezone.util';
|
||||
|
||||
// 2026-09-06 19:30:00 UTC — the mock EPG fixture's shape, a Saturday in BST.
|
||||
const EPOCH = 1_788_723_000;
|
||||
|
||||
describe('xtream-server-timezone.util', () => {
|
||||
describe('isSupportedTimeZoneName', () => {
|
||||
it('accepts IANA names and the UTC alias', () => {
|
||||
expect(isSupportedTimeZoneName('Europe/London')).toBe(true);
|
||||
expect(isSupportedTimeZoneName('America/Sao_Paulo')).toBe(true);
|
||||
expect(isSupportedTimeZoneName('UTC')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the offset spellings panels sometimes send', () => {
|
||||
expect(isSupportedTimeZoneName('UTC+3')).toBe(false);
|
||||
expect(isSupportedTimeZoneName('')).toBe(false);
|
||||
expect(isSupportedTimeZoneName('Mars/Olympus')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixed offset form', () => {
|
||||
it('round-trips positive, negative, zero and half-hour offsets', () => {
|
||||
expect(formatFixedOffsetTimeZone(180)).toBe('UTC+03:00');
|
||||
expect(formatFixedOffsetTimeZone(-210)).toBe('UTC-03:30');
|
||||
expect(formatFixedOffsetTimeZone(0)).toBe('UTC');
|
||||
expect(parseFixedOffsetTimeZone('UTC+03:00')).toBe(180);
|
||||
expect(parseFixedOffsetTimeZone('UTC-03:30')).toBe(-210);
|
||||
expect(parseFixedOffsetTimeZone('UTC')).toBe(0);
|
||||
});
|
||||
|
||||
it('does not mistake other strings for the stored form', () => {
|
||||
expect(parseFixedOffsetTimeZone('UTC+3')).toBeNull();
|
||||
expect(parseFixedOffsetTimeZone('Europe/London')).toBeNull();
|
||||
expect(parseFixedOffsetTimeZone('utc')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveXtreamServerUtcOffsetMinutes', () => {
|
||||
it('reads the offset from the panel clock pair', () => {
|
||||
// 19:30 UTC shown as 22:30 → the panel runs at UTC+3.
|
||||
expect(
|
||||
deriveXtreamServerUtcOffsetMinutes('2026-09-06 22:30:00', EPOCH)
|
||||
).toBe(180);
|
||||
expect(
|
||||
deriveXtreamServerUtcOffsetMinutes(
|
||||
'2026-09-06 16:30:00',
|
||||
String(EPOCH)
|
||||
)
|
||||
).toBe(-180);
|
||||
});
|
||||
|
||||
it('snaps a second of clock skew to the real offset', () => {
|
||||
expect(
|
||||
deriveXtreamServerUtcOffsetMinutes('2026-09-06 22:29:59', EPOCH)
|
||||
).toBe(180);
|
||||
});
|
||||
|
||||
it('rejects missing, malformed or impossible values', () => {
|
||||
expect(
|
||||
deriveXtreamServerUtcOffsetMinutes(undefined, EPOCH)
|
||||
).toBeNull();
|
||||
expect(
|
||||
deriveXtreamServerUtcOffsetMinutes('2026-09-06 22:30:00', null)
|
||||
).toBeNull();
|
||||
expect(
|
||||
deriveXtreamServerUtcOffsetMinutes('yesterday', EPOCH)
|
||||
).toBeNull();
|
||||
expect(
|
||||
deriveXtreamServerUtcOffsetMinutes('2026-09-06 22:30:00', 0)
|
||||
).toBeNull();
|
||||
// 20 hours ahead is not a real offset — a broken clock pair.
|
||||
expect(
|
||||
deriveXtreamServerUtcOffsetMinutes('2026-09-07 15:30:00', EPOCH)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveXtreamServerTimezone', () => {
|
||||
it('prefers a resolvable IANA name', () => {
|
||||
expect(
|
||||
resolveXtreamServerTimezone({
|
||||
timezone: ' Europe/London ',
|
||||
time_now: '2026-09-06 22:30:00',
|
||||
timestamp_now: EPOCH,
|
||||
})
|
||||
).toBe('Europe/London');
|
||||
});
|
||||
|
||||
it('falls back to the clock-derived fixed offset for an unusable name', () => {
|
||||
expect(
|
||||
resolveXtreamServerTimezone({
|
||||
timezone: 'UTC+3',
|
||||
time_now: '2026-09-06 22:30:00',
|
||||
timestamp_now: EPOCH,
|
||||
})
|
||||
).toBe('UTC+03:00');
|
||||
expect(
|
||||
resolveXtreamServerTimezone({
|
||||
timezone: '',
|
||||
time_now: '2026-09-06 19:30:00',
|
||||
timestamp_now: String(EPOCH),
|
||||
})
|
||||
).toBe('UTC');
|
||||
});
|
||||
|
||||
it('reports nothing when neither field is trustworthy', () => {
|
||||
expect(resolveXtreamServerTimezone(undefined)).toBeUndefined();
|
||||
expect(resolveXtreamServerTimezone({})).toBeUndefined();
|
||||
expect(
|
||||
resolveXtreamServerTimezone({
|
||||
timezone: 'UTC+3',
|
||||
time_now: 'n/a',
|
||||
timestamp_now: EPOCH,
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatXtreamCatchupStart', () => {
|
||||
it('formats in the IANA zone, honouring DST', () => {
|
||||
expect(formatXtreamCatchupStart(EPOCH, 'Europe/London')).toBe(
|
||||
'2026-09-06:20-30'
|
||||
);
|
||||
expect(formatXtreamCatchupStart(EPOCH, 'America/Sao_Paulo')).toBe(
|
||||
'2026-09-06:16-30'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats in a stored fixed offset', () => {
|
||||
expect(formatXtreamCatchupStart(EPOCH, 'UTC+03:00')).toBe(
|
||||
'2026-09-06:22-30'
|
||||
);
|
||||
expect(formatXtreamCatchupStart(EPOCH, 'UTC-03:30')).toBe(
|
||||
'2026-09-06:16-00'
|
||||
);
|
||||
expect(formatXtreamCatchupStart(EPOCH, 'UTC')).toBe(
|
||||
'2026-09-06:19-30'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders midnight as 00, never 24', () => {
|
||||
const midnightUtc = Date.UTC(2026, 8, 7, 0, 0, 0) / 1000;
|
||||
expect(formatXtreamCatchupStart(midnightUtc, 'UTC')).toBe(
|
||||
'2026-09-07:00-00'
|
||||
);
|
||||
expect(formatXtreamCatchupStart(midnightUtc, 'Etc/GMT')).toBe(
|
||||
'2026-09-07:00-00'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the local clock only when no timezone is usable', () => {
|
||||
const date = new Date(EPOCH * 1000);
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
const local = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`;
|
||||
expect(formatXtreamCatchupStart(EPOCH, undefined)).toBe(local);
|
||||
expect(formatXtreamCatchupStart(EPOCH, 'UTC+3')).toBe(local);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseXtreamServerLocalDateTime', () => {
|
||||
it('converts a server-local string back to the epoch it came from', () => {
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime(
|
||||
'2026-09-06 20:30:00',
|
||||
'Europe/London'
|
||||
)
|
||||
).toBe(EPOCH);
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime(
|
||||
'2026-09-06 16:30:00',
|
||||
'America/Sao_Paulo'
|
||||
)
|
||||
).toBe(EPOCH);
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-09-06 22:30', 'UTC+03:00')
|
||||
).toBe(EPOCH);
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-09-06T19:30:00', 'UTC')
|
||||
).toBe(EPOCH);
|
||||
});
|
||||
|
||||
it('resolves wall clocks right after a DST switch on the correct side', () => {
|
||||
// Europe/Berlin left DST at 2026-10-25 03:00 CEST → 02:00 CET.
|
||||
// 03:30 local is unambiguous CET (UTC+1) → 02:30 UTC.
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime(
|
||||
'2026-10-25 03:30:00',
|
||||
'Europe/Berlin'
|
||||
)
|
||||
).toBe(Date.UTC(2026, 9, 25, 2, 30, 0) / 1000);
|
||||
// 2026-03-29 03:30 CEST (first hour after the spring switch) →
|
||||
// 01:30 UTC.
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime(
|
||||
'2026-03-29 03:30:00',
|
||||
'Europe/Berlin'
|
||||
)
|
||||
).toBe(Date.UTC(2026, 2, 29, 1, 30, 0) / 1000);
|
||||
});
|
||||
|
||||
it('rejects out-of-range fields instead of letting Date.UTC roll them over', () => {
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-13-01 25:00:00', 'UTC')
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-02-30 10:00:00', 'UTC')
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-09-06 23:60:00', 'UTC')
|
||||
).toBeNull();
|
||||
expect(
|
||||
deriveXtreamServerUtcOffsetMinutes('2026-09-06 24:00:00', EPOCH)
|
||||
).toBeNull();
|
||||
// A leap day is a real date.
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2028-02-29 00:00:00', 'UTC')
|
||||
).toBe(Date.UTC(2028, 1, 29) / 1000);
|
||||
});
|
||||
|
||||
it('returns null for malformed strings or an unusable timezone', () => {
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-09-06', 'UTC')
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-09-06 20:30:00', 'UTC+3')
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-09-06 20:30:00', undefined)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Xtream server clock policy.
|
||||
*
|
||||
* An Xtream panel interprets every wall-clock string it exchanges with a
|
||||
* client in ITS OWN timezone: the `start` / `end` strings of
|
||||
* `get_simple_data_table` are `date('Y-m-d H:i:s')` output, and the
|
||||
* `{Y-m-d:H-M}` segment of a timeshift URL goes through `strtotime()` with
|
||||
* the same PHP default timezone. That timezone is reported once, in
|
||||
* `server_info` of the account-info response, as an IANA name plus the
|
||||
* server's current wall clock (`time_now`) and epoch (`timestamp_now`).
|
||||
*
|
||||
* `resolveXtreamServerTimezone` turns that report into ONE persistable
|
||||
* string — the IANA name when the runtime's ICU knows it, otherwise a fixed
|
||||
* `UTC±HH:MM` offset derived from the two clock fields — and the two
|
||||
* conversion helpers below accept either form, so a panel whose timezone
|
||||
* name the runtime cannot resolve (`UTC+3`, a typo, an unknown alias) still
|
||||
* gets its catch-up requests and EPG strings converted correctly instead of
|
||||
* silently falling back to the viewer's local clock (issue #1562). The
|
||||
* zone-agnostic primitives live in `xtream-server-clock.util.ts`.
|
||||
*/
|
||||
|
||||
import {
|
||||
formatFixedOffsetTimeZone,
|
||||
isSupportedTimeZoneName,
|
||||
MINUTE_MS,
|
||||
padTwo,
|
||||
parseFixedOffsetTimeZone,
|
||||
parseNaiveUtcMs,
|
||||
wallClockPartsAt,
|
||||
zoneOffsetMinutesAt,
|
||||
} from './xtream-server-clock.util';
|
||||
|
||||
export {
|
||||
formatFixedOffsetTimeZone,
|
||||
isSupportedTimeZoneName,
|
||||
parseFixedOffsetTimeZone,
|
||||
} from './xtream-server-clock.util';
|
||||
|
||||
export interface XtreamServerClockInfo {
|
||||
timezone?: string | null;
|
||||
time_now?: string | null;
|
||||
timestamp_now?: number | string | null;
|
||||
}
|
||||
|
||||
/** Real-world UTC offsets span -12:00 … +14:00. */
|
||||
const MAX_UTC_OFFSET_MINUTES = 14 * 60;
|
||||
/** Offsets are multiples of 15 min; snapping absorbs second-level clock skew. */
|
||||
const OFFSET_GRANULARITY_MINUTES = 15;
|
||||
|
||||
/**
|
||||
* The server's UTC offset in minutes derived from its own clock report:
|
||||
* `time_now` read as a naive UTC wall clock minus `timestamp_now`. Both
|
||||
* fields are produced by the same request, so the difference IS the offset
|
||||
* (snapped to 15 minutes to absorb a second of skew). `null` when either
|
||||
* field is missing, malformed, or the result is not a real-world offset.
|
||||
*/
|
||||
export function deriveXtreamServerUtcOffsetMinutes(
|
||||
timeNow: string | null | undefined,
|
||||
timestampNow: number | string | null | undefined
|
||||
): number | null {
|
||||
const wallClockMs = parseNaiveUtcMs(timeNow);
|
||||
const epochSeconds = Number(timestampNow);
|
||||
if (
|
||||
wallClockMs === null ||
|
||||
!Number.isFinite(epochSeconds) ||
|
||||
epochSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const rawMinutes = (wallClockMs / 1000 - epochSeconds) / 60;
|
||||
const snapped =
|
||||
Math.round(rawMinutes / OFFSET_GRANULARITY_MINUTES) *
|
||||
OFFSET_GRANULARITY_MINUTES;
|
||||
return Math.abs(snapped) <= MAX_UTC_OFFSET_MINUTES ? snapped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single persistable timezone string for a panel: its IANA name when
|
||||
* the runtime resolves it, else a fixed offset derived from the clock
|
||||
* fields, else `undefined` (nothing trustworthy was reported).
|
||||
*
|
||||
* The fixed offset is a snapshot of the panel's clock and carries no DST
|
||||
* rules: for such a panel, programmes on the far side of a DST switch are
|
||||
* off by an hour until the next account-info check refreshes the offset.
|
||||
* That is the most a panel with an unusable name gives away — Xtream Codes
|
||||
* itself reports PHP timezone identifiers, which are IANA names, so the
|
||||
* snapshot only ever serves non-standard servers, where the alternative is
|
||||
* the viewer's clock, wrong in every season.
|
||||
*/
|
||||
export function resolveXtreamServerTimezone(
|
||||
serverInfo: XtreamServerClockInfo | null | undefined
|
||||
): string | undefined {
|
||||
const name = serverInfo?.timezone?.trim();
|
||||
if (name && isSupportedTimeZoneName(name)) {
|
||||
return name;
|
||||
}
|
||||
const offsetMinutes = deriveXtreamServerUtcOffsetMinutes(
|
||||
serverInfo?.time_now,
|
||||
serverInfo?.timestamp_now
|
||||
);
|
||||
return offsetMinutes === null
|
||||
? undefined
|
||||
: formatFixedOffsetTimeZone(offsetMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Y-m-d:H-M` segment of a timeshift URL for an epoch, expressed in the
|
||||
* server's clock. Accepts both stored forms; with no usable timezone the
|
||||
* viewer's local clock is the only remaining guess.
|
||||
*/
|
||||
export function formatXtreamCatchupStart(
|
||||
timestampSeconds: number,
|
||||
serverTimezone: string | null | undefined
|
||||
): string {
|
||||
const parts = wallClockPartsAt(timestampSeconds * 1000, serverTimezone);
|
||||
return `${parts.year}-${padTwo(parts.month)}-${padTwo(parts.day)}:${padTwo(parts.hour)}-${padTwo(parts.minute)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch seconds for a `YYYY-MM-DD HH:mm[:ss]` string that the SERVER wrote
|
||||
* in its own timezone. `null` when the string is malformed or the timezone
|
||||
* is unusable — callers keep their previous interpretation then.
|
||||
*/
|
||||
export function parseXtreamServerLocalDateTime(
|
||||
value: string | null | undefined,
|
||||
serverTimezone: string | null | undefined
|
||||
): number | null {
|
||||
const naiveUtcMs = parseNaiveUtcMs(value);
|
||||
if (naiveUtcMs === null || !serverTimezone) {
|
||||
return null;
|
||||
}
|
||||
const fixedOffset = parseFixedOffsetTimeZone(serverTimezone);
|
||||
if (fixedOffset !== null) {
|
||||
return Math.floor((naiveUtcMs - fixedOffset * MINUTE_MS) / 1000);
|
||||
}
|
||||
if (!isSupportedTimeZoneName(serverTimezone)) {
|
||||
return null;
|
||||
}
|
||||
// The zone's offset depends on the instant (DST), which is what we are
|
||||
// solving for: take the offset at the naive guess, then re-read it at
|
||||
// the corrected instant so a string right after a DST switch lands on
|
||||
// the correct side of it.
|
||||
const firstGuessMs =
|
||||
naiveUtcMs -
|
||||
zoneOffsetMinutesAt(naiveUtcMs, serverTimezone) * MINUTE_MS;
|
||||
const resolvedMs =
|
||||
naiveUtcMs -
|
||||
zoneOffsetMinutesAt(firstGuessMs, serverTimezone) * MINUTE_MS;
|
||||
return Math.floor(resolvedMs / 1000);
|
||||
}
|
||||
Reference in new issue
Block a user