Merge pull request #885 from sapireli/fix/xtream-catchup-server-timezone

fix(xtream): use server timezone when constructing catchup/timeshift URLs
This commit is contained in:
4gray authored and GitHub committed 2026-05-09 19:28:16 +02:00
commit 4a755b43ec
6 files changed
+110 -14

No files matched your search

@@ -32,6 +32,7 @@ export interface XtreamPlaylistData {
userAgent?: string;
referrer?: string;
origin?: string;
serverTimezone?: string;
}
/**
@@ -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);
});
});
});
@@ -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,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())}`;
}
}
@@ -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);
@@ -477,7 +477,8 @@ describe('LiveStreamLayoutComponent', () => {
},
101,
1775296800,
1775300400
1775300400,
undefined
);
expect(xtreamStore.openPlayer).toHaveBeenCalledWith(
'https://example.com/timeshift.ts',
@@ -486,7 +486,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
},
item.xtream_id,
startTimestamp,
stopTimestamp
stopTimestamp,
playlist.serverTimezone
);
this.activePlayback.set({