From 30b8533e3a02669d3f9fe451c7489ea65aa2025a Mon Sep 17 00:00:00 2001 From: Eliran Sapir <114778791+sapireli@users.noreply.github.com> Date: Thu, 30 Apr 2026 05:51:42 -0400 Subject: [PATCH 1/2] fix(xtream): use server timezone when constructing catchup/timeshift URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the server timezone returned by the Xtream API (`server_info.timezone`) was fetched but never applied when formatting the start timestamp in catchup URLs. This caused timeshift requests to use the client's local time instead of the server's local time, resulting in wrong or missing archive content. Changes: - Store `server_info.timezone` on the playlist state after a successful portal status check (`with-portal.feature.ts`) - Thread `serverTimezone?` through `resolveCatchupUrl` → `constructCatchupUrl` → `formatCatchupStartTime` in `XtreamUrlService` - `formatCatchupStartTime` now uses `Intl.DateTimeFormat` with the server timezone when available, falling back to local time when not set - Pass `playlist.serverTimezone` from `LiveStreamLayoutComponent.playCatchup` - Add `serverTimezone?` field to `XtreamPlaylistData` interface Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + .../xtream-data-source.interface.ts | 1 + .../lib/services/xtream-url.service.spec.ts | 31 +++++++++++ .../src/lib/services/xtream-url.service.ts | 54 ++++++++++++++----- .../stores/features/with-portal.feature.ts | 13 +++++ .../live-stream-layout.component.spec.ts | 3 +- .../live-stream-layout.component.ts | 3 +- 7 files changed, 92 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index f0fa8c5e3..cf16152ae 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ testem.log Thumbs.db .angular .astro +.claude/settings.local.json diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index de9b5e181..bad042b38 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -32,6 +32,7 @@ export interface XtreamPlaylistData { userAgent?: string; referrer?: string; origin?: string; + serverTimezone?: string; } /** diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index 0fc4da7a5..2bbc3cea7 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -76,4 +76,35 @@ describe('XtreamUrlService', () => { 'legacy' ); }); + + describe('formatCatchupStartTime (via constructCatchupUrl)', () => { + // 2025-03-01 02:00:00 UTC = 2025-02-28 21:00:00 America/New_York + const timestamp = 1740794400; + + it('formats time in the server timezone when provided', () => { + const url = service.constructCatchupUrl( + credentials, + 101, + timestamp, + timestamp + 3600, + 'rest', + 'America/New_York' + ); + expect(url).toContain('2025-02-28:21-00'); + }); + + it('falls back to client local time when no timezone is given', () => { + const url = service.constructCatchupUrl( + credentials, + 101, + timestamp, + timestamp + 3600, + 'rest' + ); + const date = new Date(timestamp * 1000); + const pad = (n: number) => String(n).padStart(2, '0'); + const expected = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`; + expect(url).toContain(expected); + }); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index dcf62da97..65e221a6d 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -102,13 +102,17 @@ export class XtreamUrlService { streamId: number, startTimestamp: number, stopTimestamp: number, - scheme: XtreamCatchupScheme + scheme: XtreamCatchupScheme, + serverTimezone?: string ): string { const durationMinutes = Math.max( 1, Math.round((stopTimestamp - startTimestamp) / 60) ); - const timeString = this.formatCatchupStartTime(startTimestamp); + const timeString = this.formatCatchupStartTime( + startTimestamp, + serverTimezone + ); if (scheme === 'legacy') { const params = new URLSearchParams({ @@ -129,14 +133,16 @@ export class XtreamUrlService { credentials: XtreamCredentials, streamId: number, startTimestamp: number, - stopTimestamp: number + stopTimestamp: number, + serverTimezone?: string ): Promise { const scheme = await this.getCatchupScheme( playlistId, credentials, streamId, startTimestamp, - stopTimestamp + stopTimestamp, + serverTimezone ); return this.constructCatchupUrl( @@ -144,7 +150,8 @@ export class XtreamUrlService { streamId, startTimestamp, stopTimestamp, - scheme + scheme, + serverTimezone ); } @@ -153,7 +160,8 @@ export class XtreamUrlService { credentials: XtreamCredentials, streamId: number, startTimestamp: number, - stopTimestamp: number + stopTimestamp: number, + serverTimezone?: string ): Promise { const cacheKey = `${XTREAM_CATCHUP_SCHEME_KEY_PREFIX}${playlistId}`; const cached = this.catchupSchemeCache.get(cacheKey); @@ -177,7 +185,8 @@ export class XtreamUrlService { credentials, streamId, startTimestamp, - stopTimestamp + stopTimestamp, + serverTimezone ).finally(() => { this.catchupSchemeRequests.delete(cacheKey); }); @@ -191,21 +200,24 @@ export class XtreamUrlService { credentials: XtreamCredentials, streamId: number, startTimestamp: number, - stopTimestamp: number + stopTimestamp: number, + serverTimezone?: string ): Promise { const restUrl = this.constructCatchupUrl( credentials, streamId, startTimestamp, stopTimestamp, - 'rest' + 'rest', + serverTimezone ); const legacyUrl = this.constructCatchupUrl( credentials, streamId, startTimestamp, stopTimestamp, - 'legacy' + 'legacy', + serverTimezone ); const restStatus = await this.probeCatchupUrl(restUrl); @@ -252,10 +264,28 @@ export class XtreamUrlService { ); } - private formatCatchupStartTime(timestamp: number): string { + private formatCatchupStartTime( + timestamp: number, + timezone?: string + ): string { const date = new Date(timestamp * 1000); - const pad = (value: number) => String(value).padStart(2, '0'); + if (timezone) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes) => + parts.find((p) => p.type === type)?.value ?? '00'; + return `${get('year')}-${get('month')}-${get('day')}:${get('hour')}-${get('minute')}`; + } + + const pad = (value: number) => String(value).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`; } } 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 c4a63173c..449f26902 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 @@ -140,7 +140,20 @@ export function withPortal() { const response = await apiService.getAccountInfo(credentials); const portalStatus = resolvePortalStatus(response); + const serverTimezone = + response?.server_info?.timezone ?? undefined; patchState(store, { portalStatus }); + if (serverTimezone) { + const current = store.currentPlaylist(); + if (current) { + patchState(store, { + currentPlaylist: { + ...current, + serverTimezone, + }, + }); + } + } return portalStatus; } catch (error) { logger.error('Error checking portal status', error); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index a0e08ea33..656c693dc 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -288,7 +288,8 @@ describe('LiveStreamLayoutComponent', () => { }, 101, 1775296800, - 1775300400 + 1775300400, + undefined ); expect(xtreamStore.openPlayer).toHaveBeenCalledWith( 'https://example.com/timeshift.ts', diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index bfc6b8a08..c40744ec6 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -403,7 +403,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { }, item.xtream_id, startTimestamp, - stopTimestamp + stopTimestamp, + playlist.serverTimezone ); this.activeStreamUrl.set(catchupUrl); From c6562e68824a1c3d7c3de485cef6de9dbb19a61c Mon Sep 17 00:00:00 2001 From: Eliran Sapir <114778791+sapireli@users.noreply.github.com> Date: Thu, 30 Apr 2026 05:58:41 -0400 Subject: [PATCH 2/2] fix(xtream): guard against invalid timezone strings in catchup formatter Intl.DateTimeFormat throws RangeError for non-IANA timezone values (e.g. 'UTC+5', offset strings) that some Xtream servers return in server_info.timezone. Wrap the formatter in a try-catch and fall back to client local-time formatting on failure. Co-Authored-By: Claude Sonnet 4.6 --- .../lib/services/xtream-url.service.spec.ts | 15 ++++++++++ .../src/lib/services/xtream-url.service.ts | 28 +++++++++++-------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index 2bbc3cea7..2cf32156b 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -106,5 +106,20 @@ describe('XtreamUrlService', () => { const expected = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`; expect(url).toContain(expected); }); + + it('falls back to client local time when an invalid timezone string is given', () => { + const url = service.constructCatchupUrl( + credentials, + 101, + timestamp, + timestamp + 3600, + 'rest', + 'UTC+5' + ); + const date = new Date(timestamp * 1000); + const pad = (n: number) => String(n).padStart(2, '0'); + const expected = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}:${pad(date.getHours())}-${pad(date.getMinutes())}`; + expect(url).toContain(expected); + }); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index 65e221a6d..0d63c0937 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -271,18 +271,22 @@ export class XtreamUrlService { const date = new Date(timestamp * 1000); if (timezone) { - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone: timezone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }).formatToParts(date); - const get = (type: Intl.DateTimeFormatPartTypes) => - parts.find((p) => p.type === type)?.value ?? '00'; - return `${get('year')}-${get('month')}-${get('day')}:${get('hour')}-${get('minute')}`; + try { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(date); + const get = (type: Intl.DateTimeFormatPartTypes) => + parts.find((p) => p.type === type)?.value ?? '00'; + return `${get('year')}-${get('month')}-${get('day')}:${get('hour')}-${get('minute')}`; + } catch { + // Invalid timezone string — fall through to local-time formatting + } } const pad = (value: number) => String(value).padStart(2, '0');