mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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>
This commit is contained in:
1 parent
0e3b44e2e8
commit
cd364525fa
2 files changed
+224
-189
No files matched your search
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Xtream server clock helpers.
|
||||
* 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
|
||||
@@ -15,69 +15,37 @@
|
||||
* 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).
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** 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}))?$/;
|
||||
/** 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;
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
const timeZoneSupportCache = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* 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}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's UTC offset in minutes derived from its own clock report:
|
||||
@@ -145,7 +113,7 @@ export function formatXtreamCatchupStart(
|
||||
serverTimezone: string | null | undefined
|
||||
): string {
|
||||
const parts = wallClockPartsAt(timestampSeconds * 1000, serverTimezone);
|
||||
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}:${pad(parts.hour)}-${pad(parts.minute)}`;
|
||||
return `${parts.year}-${padTwo(parts.month)}-${padTwo(parts.day)}:${padTwo(parts.hour)}-${padTwo(parts.minute)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,139 +148,3 @@ export function parseXtreamServerLocalDateTime(
|
||||
zoneOffsetMinutesAt(firstGuessMs, serverTimezone) * MINUTE_MS;
|
||||
return Math.floor(resolvedMs / 1000);
|
||||
}
|
||||
|
||||
interface WallClockParts {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
function wallClockPartsAt(
|
||||
instantMs: number,
|
||||
serverTimezone: string | null | undefined
|
||||
): WallClockParts {
|
||||
if (serverTimezone) {
|
||||
const fixedOffset = parseFixedOffsetTimeZone(serverTimezone);
|
||||
if (fixedOffset !== null) {
|
||||
return utcParts(new Date(instantMs + fixedOffset * MINUTE_MS));
|
||||
}
|
||||
if (isSupportedTimeZoneName(serverTimezone)) {
|
||||
return zoneParts(instantMs, serverTimezone);
|
||||
}
|
||||
}
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD HH:mm[:ss]` read as if it were UTC; `null` when malformed. */
|
||||
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;
|
||||
}
|
||||
// `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 {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
Reference in new issue
Block a user