diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index a68ed6b22..7f48a9e5f 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -269,7 +269,11 @@ timezone is learned by `withPortal.checkPortalStatus()` and normalized by - otherwise (`UTC+3`, a typo, an unknown alias) the offset is derived from the clock pair the same response carries — `time_now` read as a naive UTC wall clock minus `timestamp_now`, snapped to 15 minutes — and stored as - `UTC±HH:MM`; + `UTC±HH:MM`. This is a snapshot without 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. Xtream Codes reports PHP + timezone identifiers (IANA names), so the snapshot only serves + non-standard servers, where the alternative was the viewer's clock; - with neither, nothing is stored and the URL falls back to the viewer's clock, the only remaining guess. diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts index c4976fe2b..58d34e90d 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts @@ -199,6 +199,58 @@ describe('withPortal', () => { expect(transformPlaylistMeta).not.toHaveBeenCalled(); }); + it('persists a late answer under the playlist that asked, never onto the one selected meanwhile', async () => { + const other: XtreamPlaylistData = { + ...PLAYLIST, + id: 'playlist-2', + name: 'Other portal', + serverUrl: 'https://other.example.com', + }; + let answer!: (value: unknown) => void; + apiService.getAccountInfo.mockReturnValue( + new Promise((resolve) => { + answer = resolve; + }) + ); + + const pending = store.checkPortalStatus(); + store.setCurrentPlaylist(other); + answer({ + user_info: { auth: 1, exp_date: '0', status: 'Active' }, + server_info: { timezone: 'Europe/London' }, + }); + + await expect(pending).resolves.toBe('active'); + expect(store.currentPlaylist()).toEqual(other); + expect(store.portalStatus()).toBe('unavailable'); + expect(transformPlaylistMeta).toHaveBeenCalledWith( + PLAYLIST.id, + expect.any(Function) + ); + expect(storedPlaylist.serverTimezone).toBe('Europe/London'); + }); + + it('does not mark a playlist selected meanwhile unavailable for the earlier playlist’s failure', async () => { + const other: XtreamPlaylistData = { ...PLAYLIST, id: 'playlist-2' }; + let fail!: (reason: unknown) => void; + apiService.getAccountInfo.mockReturnValue( + new Promise((_resolve, reject) => { + fail = reject; + }) + ); + + const pending = store.checkPortalStatus(); + store.setCurrentPlaylist(other); + respondWith({ timezone: 'UTC' }); + await store.checkPortalStatus(); + expect(store.portalStatus()).toBe('active'); + + fail(new Error('panel down')); + + await expect(pending).resolves.toBe('unavailable'); + expect(store.portalStatus()).toBe('active'); + }); + it('still reports the portal status when persisting the timezone fails', async () => { transformPlaylistMeta.mockImplementation(() => { throw new Error('storage unavailable'); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index a6b1f7e3b..497885ed7 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -166,10 +166,17 @@ export function withPortal() { .map((format) => format.trim()) .filter(Boolean) : undefined; - patchState(store, { portalStatus }); + // The answer belongs to the playlist whose + // credentials were sent: its timezone is persisted + // under THAT id regardless, while the store is + // patched only if that playlist is still the + // selected one — a source switch during the request + // must not hand playlist A's status or clock to + // playlist B. const current = store.currentPlaylist(); - if (current) { + if (current?.id === playlist.id) { patchState(store, { + portalStatus, currentPlaylist: { ...current, allowedOutputFormats, @@ -178,20 +185,22 @@ export function withPortal() { : {}), }, }); - if ( - serverTimezone && - serverTimezone !== current.serverTimezone - ) { - await rememberServerTimezone( - current.id, - serverTimezone - ); - } + } + if ( + serverTimezone && + serverTimezone !== playlist.serverTimezone + ) { + await rememberServerTimezone( + playlist.id, + serverTimezone + ); } return portalStatus; } catch (error) { logger.error('Error checking portal status', error); - patchState(store, { portalStatus: 'unavailable' }); + if (store.currentPlaylist()?.id === playlist.id) { + patchState(store, { portalStatus: 'unavailable' }); + } return 'unavailable'; } }, diff --git a/libs/shared/interfaces/src/lib/xtream-server-timezone.util.spec.ts b/libs/shared/interfaces/src/lib/xtream-server-timezone.util.spec.ts index af8a1d5dd..590ad484e 100644 --- a/libs/shared/interfaces/src/lib/xtream-server-timezone.util.spec.ts +++ b/libs/shared/interfaces/src/lib/xtream-server-timezone.util.spec.ts @@ -206,6 +206,25 @@ describe('xtream-server-timezone.util', () => { ).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') diff --git a/libs/shared/interfaces/src/lib/xtream-server-timezone.util.ts b/libs/shared/interfaces/src/lib/xtream-server-timezone.util.ts index fc21daef1..b48b7a7dc 100644 --- a/libs/shared/interfaces/src/lib/xtream-server-timezone.util.ts +++ b/libs/shared/interfaces/src/lib/xtream-server-timezone.util.ts @@ -110,6 +110,14 @@ export function deriveXtreamServerUtcOffsetMinutes( * 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 @@ -269,15 +277,40 @@ function parseNaiveUtcMs(value: string | null | undefined): number | null { return null; } const [, year, month, day, hour, minute, second = '0'] = match; - const ms = Date.UTC( + const fields = [ Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), - Number(second) + Number(second), + ]; + const ms = Date.UTC( + fields[0], + fields[1], + fields[2], + fields[3], + fields[4], + fields[5] ); - return Number.isFinite(ms) ? ms : null; + if (!Number.isFinite(ms)) { + return null; + } + // `Date.UTC` rolls out-of-range fields over (`2026-13-01 25:00` becomes + // a real instant in 2027); only a string that reads back unchanged is a + // date the panel actually wrote. + const date = new Date(ms); + const readBack = [ + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + ]; + return fields.every((field, index) => field === readBack[index]) + ? ms + : null; } function pad(value: number): string {