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>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 23:20:54 +02:00
1 parent 94623590c4
commit 89beb2c4a5
5 files changed
+133 -16

No files matched your search

@@ -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.
@@ -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');
@@ -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';
}
},
@@ -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')
@@ -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 {