diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index 875406256..7a0125268 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -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; + 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); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 22d58326f..c00423a7e 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -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, diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 39605087b..517b34c81 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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. 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 92db66b06..6332b9baf 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 @@ -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' ); }); 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 0ec6014db..44461836e 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 @@ -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 + Promise >(); /** @@ -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 { - 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 { - const cacheKey = `${XTREAM_CATCHUP_SCHEME_KEY_PREFIX}${playlistId}`; + ): Promise { + 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 { - const restUrl = this.constructCatchupUrl( - credentials, - streamId, - startTimestamp, - stopTimestamp, - 'rest', - serverTimezone - ); - const legacyUrl = this.constructCatchupUrl( - credentials, - streamId, - startTimestamp, - stopTimestamp, - 'legacy', - serverTimezone - ); + ): Promise { + 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 { @@ -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 { 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 40bbd2a04..957698e8b 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 @@ -746,6 +746,7 @@ describe('LiveStreamLayoutComponent', () => { expect(xtreamUrlService.resolveCatchupUrl).toHaveBeenCalledWith( 'playlist-1', { + allowedOutputFormats: undefined, serverUrl: 'http://demo.example', username: 'demo', password: 'secret', 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 3ae1499c9..0a9dcde66 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 @@ -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,