fix(xtream): resolve catchup timeshift variants

This commit is contained in:
4gray committed 2026-06-27 11:51:40 +02:00
1 parent 6373c798a1
commit 12f3f1fc05
7 files changed
+327 -91

No files matched your search

@@ -86,6 +86,54 @@ describe('XtreamEvents session cancellation', () => {
expect(requestedUrl.searchParams.get('username')).toBe('user');
});
it('follows validated redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL');
const destroyProbeBody = jest.fn();
expect(probeHandler).toBeDefined();
axiosMock
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 302,
headers: { location: '/media.ts' },
config,
})
)
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 206,
headers: {},
data: { destroy: destroyProbeBody },
config,
})
);
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { status: number; url: string };
expect(result.status).toBe(206);
expect(result.url).toBe('http://localhost:3211/media.ts');
expect(axiosMock).toHaveBeenCalledTimes(2);
const firstRequest = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
maxRedirects?: number;
method?: string;
responseType?: string;
};
expect(firstRequest.method).toBe('GET');
expect(firstRequest.headers).toEqual(
expect.objectContaining({ Range: 'bytes=0-4095' })
);
expect(firstRequest.maxRedirects).toBe(0);
expect(firstRequest.responseType).toBe('stream');
expect(destroyProbeBody).toHaveBeenCalledTimes(1);
});
it('aborts requests that were registered with only a session id', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
@@ -11,7 +11,7 @@ import {
normalizeXtreamServerUrl,
} from '@iptvnator/shared/interfaces';
import { emitPortalDebugEvent } from './portal-debug.events';
import { assertRemoteUrlAllowed, UnsafeUrlError } from './url-safety';
import { UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
export default class XtreamEvents {
@@ -299,45 +299,33 @@ ipcMain.handle(
method?: 'GET' | 'HEAD';
}
) => {
// Probe URLs must be http(s), but may target private/LAN Xtream hosts
// for self-hosted setups. Redirects stay disabled below so a validated
// URL cannot bounce to a different private target.
try {
await assertRemoteUrlAllowed(payload.url, {
allowPrivateNetworks: true,
});
} catch (error) {
return {
status: 0,
url: payload.url,
error:
error instanceof UnsafeUrlError
? error.message
: 'Invalid URL',
};
}
const method = payload.method ?? 'HEAD';
const config: AxiosRequestConfig = {
method: payload.method ?? 'HEAD',
method,
url: payload.url,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}),
},
timeout: 10000,
// SSRF hardening: do NOT follow redirects. assertRemoteUrlAllowed
// validated payload.url, but a validated public host could 3xx to an
// internal address that would never be re-checked. validateStatus
// below returns the 3xx as the probe result instead of following it.
maxRedirects: 0,
responseType: method === 'GET' ? 'stream' : undefined,
validateStatus: () => true,
};
try {
const response = await axios(config);
const response = await requestWithValidatedRedirects(
payload.url,
config,
{ allowPrivateNetworks: true }
);
const responseBody = response.data as
| { destroy?: () => void }
| undefined;
responseBody?.destroy?.();
return {
status: response.status,
url: payload.url,
url: response.config?.url ?? payload.url,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
@@ -347,6 +335,14 @@ ipcMain.handle(
};
}
if (error instanceof UnsafeUrlError) {
return {
status: 0,
url: payload.url,
error: error.message,
};
}
return {
status: 0,
url: payload.url,
@@ -72,3 +72,20 @@ application format is not allowed by the portal.
If stored Xtream playback credentials contain an invalid server URL or blank
username/password, stream URL construction returns an empty URL instead of
throwing during playback.
## Catch-Up Playback URLs
Xtream-compatible portals differ on archive playback URL shape. IPTVnator
supports these catch-up variants:
1. REST-style `/timeshift/{username}/{password}/{duration}/{start}/{streamId}.ts`
and `.m3u8`.
2. Legacy `/streaming/timeshift.php?username=...&password=...&stream=...&start=...&duration=...`
with optional `extension=ts` or `extension=m3u8`.
Electron probes concrete catch-up variants before caching a playlist-level
choice. The probe uses a short range `GET`, follows only validated redirects,
and accepts only `200` or `206` as playable. MPEG-TS is preferred before HLS
when the provider allows it because some portals return a valid HLS manifest
while the first media segment fails in Chromium/video.js. PWA fallback keeps the
REST MPEG-TS URL when no Electron probe API is available.
@@ -109,35 +109,111 @@ describe('XtreamUrlService', () => {
});
it('detects the legacy catchup scheme once and then uses the cached result', async () => {
const tsOnlyCredentials: XtreamCredentials = {
...credentials,
allowedOutputFormats: ['ts'],
};
const xtreamProbeUrl = jest
.fn()
.mockResolvedValueOnce({ status: 404 })
.mockResolvedValueOnce({ status: 302 });
.mockResolvedValueOnce({ status: 206 });
window.electron = {
xtreamProbeUrl,
} as typeof window.electron;
const firstUrl = await service.resolveCatchupUrl(
'playlist-1',
credentials,
tsOnlyCredentials,
101,
1775296800,
1775300400
);
const secondUrl = await service.resolveCatchupUrl(
'playlist-1',
credentials,
tsOnlyCredentials,
101,
1775296800,
1775300400
);
expect(firstUrl).toContain('/streaming/timeshift.php?');
expect(firstUrl).toContain('extension=ts');
expect(secondUrl).toBe(firstUrl);
expect(xtreamProbeUrl).toHaveBeenCalledTimes(2);
expect(databaseService.setAppState).toHaveBeenCalledWith(
'xtream-catchup-scheme:playlist-1',
'legacy'
'xtream-catchup-variant:v3:playlist-1',
'legacy:ts'
);
});
it('prefers playable legacy MPEG-TS catchup before HLS for video.js compatible playlists', async () => {
const xtreamProbeUrl = jest.fn(async (url: string) => ({
status:
url.includes('/streaming/timeshift.php?') &&
url.includes('extension=ts')
? 206
: 0,
}));
window.electron = {
xtreamProbeUrl,
} as typeof window.electron;
const catchupUrl = await service.resolveCatchupUrl(
'playlist-hls',
{
...credentials,
allowedOutputFormats: ['m3u8', 'ts'],
},
45,
1782532800,
1782534600,
'UTC'
);
expect(catchupUrl).toContain('/streaming/timeshift.php?');
expect(catchupUrl).toContain('stream=45');
expect(catchupUrl).toContain('start=2026-06-27%3A04-00');
expect(catchupUrl).toContain('duration=30');
expect(catchupUrl).toContain('extension=ts');
expect(xtreamProbeUrl).toHaveBeenCalledWith(
expect.stringContaining('/timeshift/'),
'GET'
);
expect(databaseService.setAppState).toHaveBeenCalledWith(
'xtream-catchup-variant:v3:playlist-hls',
'legacy:ts'
);
});
it('falls back to legacy HLS catchup when MPEG-TS probes fail', async () => {
const xtreamProbeUrl = jest.fn(async (url: string) => ({
status:
url.includes('/streaming/timeshift.php?') &&
url.includes('extension=m3u8')
? 200
: 0,
}));
window.electron = {
xtreamProbeUrl,
} as typeof window.electron;
const catchupUrl = await service.resolveCatchupUrl(
'playlist-hls-only',
{
...credentials,
allowedOutputFormats: ['m3u8', 'ts'],
},
45,
1782532800,
1782534600,
'UTC'
);
expect(catchupUrl).toContain('/streaming/timeshift.php?');
expect(catchupUrl).toContain('extension=m3u8');
expect(databaseService.setAppState).toHaveBeenCalledWith(
'xtream-catchup-variant:v3:playlist-hls-only',
'legacy:m3u8'
);
});
@@ -31,7 +31,32 @@ type XtreamVodStreamLike = XtreamVodDetails & {
readonly stream_id?: number;
};
type XtreamCatchupScheme = 'rest' | 'legacy';
const XTREAM_CATCHUP_SCHEME = {
LEGACY: 'legacy',
REST: 'rest',
} as const;
type XtreamCatchupScheme =
(typeof XTREAM_CATCHUP_SCHEME)[keyof typeof XTREAM_CATCHUP_SCHEME];
const XTREAM_CATCHUP_VARIANT = {
LEGACY: 'legacy',
LEGACY_M3U8: 'legacy:m3u8',
LEGACY_TS: 'legacy:ts',
REST_M3U8: 'rest:m3u8',
REST_TS: 'rest:ts',
} as const;
type XtreamCatchupVariant =
(typeof XTREAM_CATCHUP_VARIANT)[keyof typeof XTREAM_CATCHUP_VARIANT];
const XTREAM_CATCHUP_EXTENSIONS = {
M3U8: 'm3u8',
TS: 'ts',
} as const;
type XtreamCatchupExtension =
(typeof XTREAM_CATCHUP_EXTENSIONS)[keyof typeof XTREAM_CATCHUP_EXTENSIONS];
interface NormalizedXtreamCredentials {
password: string;
@@ -48,7 +73,7 @@ type XtreamProbeApi = {
) => Promise<{ status: number }>;
};
const XTREAM_CATCHUP_SCHEME_KEY_PREFIX = 'xtream-catchup-scheme:';
const XTREAM_CATCHUP_VARIANT_KEY_PREFIX = 'xtream-catchup-variant:v3:';
/**
* Service for constructing Xtream stream URLs.
@@ -60,11 +85,11 @@ export class XtreamUrlService {
private readonly settingsStore = inject(SettingsStore);
private readonly catchupSchemeCache = new Map<
string,
XtreamCatchupScheme
XtreamCatchupVariant
>();
private readonly catchupSchemeRequests = new Map<
string,
Promise<XtreamCatchupScheme>
Promise<XtreamCatchupVariant>
>();
/**
@@ -131,7 +156,7 @@ export class XtreamUrlService {
streamId: number,
startTimestamp: number,
stopTimestamp: number,
scheme: XtreamCatchupScheme,
scheme: XtreamCatchupScheme | XtreamCatchupVariant,
serverTimezone?: string
): string {
const normalizedCredentials = this.normalizeCredentials(credentials);
@@ -147,8 +172,10 @@ export class XtreamUrlService {
startTimestamp,
serverTimezone
);
const variant = this.normalizeCatchupVariant(scheme);
const extension = this.getCatchupVariantExtension(variant);
if (scheme === 'legacy') {
if (variant.startsWith(XTREAM_CATCHUP_SCHEME.LEGACY)) {
const params = new URLSearchParams({
username: normalizedCredentials.rawUsername,
password: normalizedCredentials.rawPassword,
@@ -156,10 +183,13 @@ export class XtreamUrlService {
start: timeString,
duration: String(durationMinutes),
});
if (extension) {
params.set('extension', extension);
}
return `${normalizedCredentials.serverUrl}/streaming/timeshift.php?${params.toString()}`;
}
return `${normalizedCredentials.serverUrl}/timeshift/${normalizedCredentials.username}/${normalizedCredentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`;
return `${normalizedCredentials.serverUrl}/timeshift/${normalizedCredentials.username}/${normalizedCredentials.password}/${durationMinutes}/${timeString}/${streamId}.${extension ?? XTREAM_CATCHUP_EXTENSIONS.TS}`;
}
async resolveCatchupUrl(
@@ -170,7 +200,7 @@ export class XtreamUrlService {
stopTimestamp: number,
serverTimezone?: string
): Promise<string> {
const scheme = await this.getCatchupScheme(
const variant = await this.getCatchupVariant(
playlistId,
credentials,
streamId,
@@ -184,29 +214,30 @@ export class XtreamUrlService {
streamId,
startTimestamp,
stopTimestamp,
scheme,
variant,
serverTimezone
);
}
private async getCatchupScheme(
private async getCatchupVariant(
playlistId: string,
credentials: XtreamCredentials,
streamId: number,
startTimestamp: number,
stopTimestamp: number,
serverTimezone?: string
): Promise<XtreamCatchupScheme> {
const cacheKey = `${XTREAM_CATCHUP_SCHEME_KEY_PREFIX}${playlistId}`;
): Promise<XtreamCatchupVariant> {
const cacheKey = `${XTREAM_CATCHUP_VARIANT_KEY_PREFIX}${playlistId}`;
const cached = this.catchupSchemeCache.get(cacheKey);
if (cached) {
return cached;
return this.normalizeCatchupVariant(cached);
}
const persisted = await this.databaseService.getAppState(cacheKey);
if (persisted === 'rest' || persisted === 'legacy') {
this.catchupSchemeCache.set(cacheKey, persisted);
return persisted;
const persistedVariant = this.parseCatchupVariant(persisted);
if (persistedVariant) {
this.catchupSchemeCache.set(cacheKey, persistedVariant);
return persistedVariant;
}
const inFlightRequest = this.catchupSchemeRequests.get(cacheKey);
@@ -214,7 +245,7 @@ export class XtreamUrlService {
return inFlightRequest;
}
const request = this.detectCatchupScheme(
const request = this.detectCatchupVariant(
cacheKey,
credentials,
streamId,
@@ -229,52 +260,41 @@ export class XtreamUrlService {
return request;
}
private async detectCatchupScheme(
private async detectCatchupVariant(
cacheKey: string,
credentials: XtreamCredentials,
streamId: number,
startTimestamp: number,
stopTimestamp: number,
serverTimezone?: string
): Promise<XtreamCatchupScheme> {
const restUrl = this.constructCatchupUrl(
credentials,
streamId,
startTimestamp,
stopTimestamp,
'rest',
serverTimezone
);
const legacyUrl = this.constructCatchupUrl(
credentials,
streamId,
startTimestamp,
stopTimestamp,
'legacy',
serverTimezone
);
): Promise<XtreamCatchupVariant> {
for (const variant of this.getCatchupVariantCandidates(credentials)) {
const url = this.constructCatchupUrl(
credentials,
streamId,
startTimestamp,
stopTimestamp,
variant,
serverTimezone
);
if (!url) {
continue;
}
if (!restUrl || !legacyUrl) {
return 'rest';
const status = await this.probeCatchupUrl(url);
if (this.isAcceptedCatchupProbeStatus(status)) {
this.catchupSchemeCache.set(cacheKey, variant);
await this.databaseService.setAppState(cacheKey, variant);
return variant;
}
}
const restStatus = await this.probeCatchupUrl(restUrl);
let detectedScheme: XtreamCatchupScheme;
if (this.isAcceptedCatchupProbeStatus(restStatus)) {
detectedScheme = 'rest';
} else {
const legacyStatus = await this.probeCatchupUrl(legacyUrl);
detectedScheme = this.isAcceptedCatchupProbeStatus(legacyStatus)
? 'legacy'
: restStatus === 404
? 'legacy'
: 'rest';
}
this.catchupSchemeCache.set(cacheKey, detectedScheme);
await this.databaseService.setAppState(cacheKey, detectedScheme);
return detectedScheme;
this.catchupSchemeCache.set(cacheKey, XTREAM_CATCHUP_VARIANT.REST_TS);
await this.databaseService.setAppState(
cacheKey,
XTREAM_CATCHUP_VARIANT.REST_TS
);
return XTREAM_CATCHUP_VARIANT.REST_TS;
}
private async probeCatchupUrl(url: string): Promise<number> {
@@ -286,7 +306,7 @@ export class XtreamUrlService {
}
try {
const result = await probeUrl(url, 'HEAD');
const result = await probeUrl(url, 'GET');
return Number(result?.status ?? 0);
} catch {
return 0;
@@ -294,14 +314,91 @@ export class XtreamUrlService {
}
private isAcceptedCatchupProbeStatus(status: number): boolean {
return status === 200 || status === 206;
}
private getCatchupVariantCandidates(
credentials: XtreamCredentials
): XtreamCatchupVariant[] {
const variants: XtreamCatchupVariant[] = [];
for (const extension of this.getPreferredCatchupExtensions(credentials)) {
variants.push(
extension === XTREAM_CATCHUP_EXTENSIONS.M3U8
? XTREAM_CATCHUP_VARIANT.REST_M3U8
: XTREAM_CATCHUP_VARIANT.REST_TS
);
variants.push(
extension === XTREAM_CATCHUP_EXTENSIONS.M3U8
? XTREAM_CATCHUP_VARIANT.LEGACY_M3U8
: XTREAM_CATCHUP_VARIANT.LEGACY_TS
);
}
variants.push(XTREAM_CATCHUP_VARIANT.LEGACY);
return [...new Set(variants)];
}
private getPreferredCatchupExtensions(
credentials: XtreamCredentials
): XtreamCatchupExtension[] {
const allowedFormats = credentials.allowedOutputFormats
?.map((format) => format.trim().toLowerCase())
.filter(Boolean);
const formats =
allowedFormats && allowedFormats.length > 0
? allowedFormats
: [
XTREAM_CATCHUP_EXTENSIONS.M3U8,
XTREAM_CATCHUP_EXTENSIONS.TS,
];
const preferred = [
XTREAM_CATCHUP_EXTENSIONS.TS,
XTREAM_CATCHUP_EXTENSIONS.M3U8,
].filter((format) => formats.includes(format));
return preferred.length > 0
? preferred
: [XTREAM_CATCHUP_EXTENSIONS.TS];
}
private normalizeCatchupVariant(
scheme: XtreamCatchupScheme | XtreamCatchupVariant
): XtreamCatchupVariant {
return (
(status >= 200 && status < 400) ||
status === 401 ||
status === 403 ||
status === 405
this.parseCatchupVariant(scheme) ?? XTREAM_CATCHUP_VARIANT.REST_TS
);
}
private parseCatchupVariant(value: string | null): XtreamCatchupVariant | null {
const variants = Object.values(XTREAM_CATCHUP_VARIANT);
return variants.includes(value as XtreamCatchupVariant)
? (value as XtreamCatchupVariant)
: null;
}
private getCatchupVariantExtension(
variant: XtreamCatchupVariant
): XtreamCatchupExtension | null {
if (
variant === XTREAM_CATCHUP_VARIANT.REST_M3U8 ||
variant === XTREAM_CATCHUP_VARIANT.LEGACY_M3U8
) {
return XTREAM_CATCHUP_EXTENSIONS.M3U8;
}
if (
variant === XTREAM_CATCHUP_VARIANT.REST_TS ||
variant === XTREAM_CATCHUP_VARIANT.LEGACY_TS
) {
return XTREAM_CATCHUP_EXTENSIONS.TS;
}
return null;
}
private normalizeCredentials(
credentials: XtreamCredentials
): NormalizedXtreamCredentials | null {
@@ -746,6 +746,7 @@ describe('LiveStreamLayoutComponent', () => {
expect(xtreamUrlService.resolveCatchupUrl).toHaveBeenCalledWith(
'playlist-1',
{
allowedOutputFormats: undefined,
serverUrl: 'http://demo.example',
username: 'demo',
password: 'secret',
@@ -622,6 +622,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl(
playlist.id,
{
allowedOutputFormats: playlist.allowedOutputFormats,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,