diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index c4acb36e7..ec1f7b5e8 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -89,8 +89,12 @@ supports these catch-up variants: 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. +choice. The cache key includes the playlist id and the normalized +`allowed_output_formats` advertised by the provider, so a catch-up variant +detected before account capabilities are known cannot force stale MPEG-TS URLs +after the portal later reports HLS-only playback. 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 405926b5e..c1f837f94 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 @@ -188,11 +188,57 @@ describe('XtreamUrlService', () => { expect(secondUrl).toBe(firstUrl); expect(xtreamProbeUrl).toHaveBeenCalledTimes(2); expect(databaseService.setAppState).toHaveBeenCalledWith( - 'xtream-catchup-variant:v3:playlist-1', + 'xtream-catchup-variant:v4:playlist-1:formats:ts', 'legacy:ts' ); }); + it('redetects catchup variants when provider output formats become known', async () => { + const xtreamProbeUrl = jest.fn(async (url: string) => ({ + status: + url.includes('/streaming/timeshift.php?') && + url.includes('extension=ts') + ? 206 + : url.includes('/streaming/timeshift.php?') && + url.includes('extension=m3u8') + ? 200 + : 0, + })); + window.electron = { + xtreamProbeUrl, + } as typeof window.electron; + + const initialUrl = await service.resolveCatchupUrl( + 'playlist-format-refresh', + credentials, + 101, + 1775296800, + 1775300400 + ); + const refreshedUrl = await service.resolveCatchupUrl( + 'playlist-format-refresh', + { + ...credentials, + allowedOutputFormats: ['m3u8'], + }, + 101, + 1775296800, + 1775300400 + ); + + expect(initialUrl).toContain('extension=ts'); + expect(refreshedUrl).toContain('extension=m3u8'); + expect(xtreamProbeUrl).toHaveBeenCalledTimes(4); + expect(databaseService.setAppState).toHaveBeenCalledWith( + 'xtream-catchup-variant:v4:playlist-format-refresh:formats:unknown', + 'legacy:ts' + ); + expect(databaseService.setAppState).toHaveBeenCalledWith( + 'xtream-catchup-variant:v4:playlist-format-refresh:formats:m3u8', + 'legacy:m3u8' + ); + }); + it('prefers playable legacy MPEG-TS catchup before HLS for video.js compatible playlists', async () => { const xtreamProbeUrl = jest.fn(async (url: string) => ({ status: @@ -227,7 +273,7 @@ describe('XtreamUrlService', () => { 'GET' ); expect(databaseService.setAppState).toHaveBeenCalledWith( - 'xtream-catchup-variant:v3:playlist-hls', + 'xtream-catchup-variant:v4:playlist-hls:formats:m3u8,ts', 'legacy:ts' ); }); @@ -259,7 +305,7 @@ describe('XtreamUrlService', () => { expect(catchupUrl).toContain('/streaming/timeshift.php?'); expect(catchupUrl).toContain('extension=m3u8'); expect(databaseService.setAppState).toHaveBeenCalledWith( - 'xtream-catchup-variant:v3:playlist-hls-only', + 'xtream-catchup-variant:v4:playlist-hls-only:formats:m3u8,ts', '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 53dfcf868..8aa9c49b3 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 @@ -74,7 +74,7 @@ type XtreamProbeApi = { ) => Promise<{ status: number }>; }; -const XTREAM_CATCHUP_VARIANT_KEY_PREFIX = 'xtream-catchup-variant:v3:'; +const XTREAM_CATCHUP_VARIANT_KEY_PREFIX = 'xtream-catchup-variant:v4:'; /** * Service for constructing Xtream stream URLs. @@ -230,7 +230,10 @@ export class XtreamUrlService { stopTimestamp: number, serverTimezone?: string ): Promise { - const cacheKey = `${XTREAM_CATCHUP_VARIANT_KEY_PREFIX}${playlistId}`; + const cacheKey = this.getCatchupVariantCacheKey( + playlistId, + credentials + ); const cached = this.catchupSchemeCache.get(cacheKey); if (cached) { return this.normalizeCatchupVariant(cached); @@ -348,9 +351,8 @@ export class XtreamUrlService { private getPreferredCatchupExtensions( credentials: XtreamCredentials ): XtreamCatchupExtension[] { - const allowedFormats = credentials.allowedOutputFormats - ?.map((format) => format.trim().toLowerCase()) - .filter(Boolean); + const allowedFormats = + this.getNormalizedAllowedOutputFormats(credentials); const formats = allowedFormats && allowedFormats.length > 0 ? allowedFormats @@ -369,6 +371,35 @@ export class XtreamUrlService { : [XTREAM_CATCHUP_EXTENSIONS.TS]; } + private getCatchupVariantCacheKey( + playlistId: string, + credentials: XtreamCredentials + ): string { + const allowedFormats = + this.getNormalizedAllowedOutputFormats(credentials); + const formatSignature = + allowedFormats && allowedFormats.length > 0 + ? [...new Set(allowedFormats)] + .sort() + .map((format) => encodeURIComponent(format)) + .join(',') + : 'unknown'; + + return `${XTREAM_CATCHUP_VARIANT_KEY_PREFIX}${playlistId}:formats:${formatSignature}`; + } + + private getNormalizedAllowedOutputFormats( + credentials: XtreamCredentials + ): string[] | undefined { + const allowedFormats = credentials.allowedOutputFormats + ?.map((format) => format.trim().toLowerCase()) + .filter(Boolean); + + return allowedFormats && allowedFormats.length > 0 + ? allowedFormats + : undefined; + } + private normalizeCatchupVariant( scheme: XtreamCatchupScheme | XtreamCatchupVariant ): XtreamCatchupVariant { @@ -436,9 +467,8 @@ export class XtreamUrlService { requestedFormat: string ): string { const requested = requestedFormat.trim().toLowerCase(); - const allowedFormats = credentials.allowedOutputFormats - ?.map((format) => format.trim().toLowerCase()) - .filter(Boolean); + const allowedFormats = + this.getNormalizedAllowedOutputFormats(credentials); if (!requested || requested === StreamFormat.AutoStreamFormat) { return this.resolveAutoLiveStreamFormat(allowedFormats);