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] 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);