fix(xtream): use server timezone when constructing catchup/timeshift URLs

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 <noreply@anthropic.com>
This commit is contained in:
Eliran SapirandClaude Sonnet 4.6 committed 2026-04-30 05:51:42 -04:00
1 parent 95e2a43626
commit 30b8533e3a
7 files changed
+92 -14

No files matched your search

+1
View File
@@ -65,3 +65,4 @@ testem.log
Thumbs.db
.angular
.astro
.claude/settings.local.json
@@ -32,6 +32,7 @@ export interface XtreamPlaylistData {
userAgent?: string;
referrer?: string;
origin?: string;
serverTimezone?: string;
}
/**
@@ -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);
});
});
});
@@ -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<string> {
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<XtreamCatchupScheme> {
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<XtreamCatchupScheme> {
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())}`;
}
}
@@ -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);
@@ -288,7 +288,8 @@ describe('LiveStreamLayoutComponent', () => {
},
101,
1775296800,
1775300400
1775300400,
undefined
);
expect(xtreamStore.openPlayer).toHaveBeenCalledWith(
'https://example.com/timeshift.ts',
@@ -403,7 +403,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
},
item.xtream_id,
startTimestamp,
stopTimestamp
stopTimestamp,
playlist.serverTimezone
);
this.activeStreamUrl.set(catchupUrl);