Files
iptvnator/libs/shared/interfaces/src/lib/xtream-server-clock.util.ts
T
4grayandClaude Fable 5.1 97b0264dee 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>
2026-09-07 19:40:47 +02:00

204 lines
6.0 KiB
TypeScript

/**
* 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(),
};
}