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 3041999d6..45634d602 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..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 @@ -76,4 +76,50 @@ 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); + }); + + 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 dcf62da97..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 @@ -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,32 @@ 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) { + 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'); 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 437b7054e..28ca38289 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 @@ -477,7 +477,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 d99625508..c88bd2962 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 @@ -486,7 +486,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { }, item.xtream_id, startTimestamp, - stopTimestamp + stopTimestamp, + playlist.serverTimezone ); this.activePlayback.set({