fix(xtream): key catchup probes by output formats

This commit is contained in:
4gray committed 2026-06-27 13:00:03 +02:00
1 parent ca81bc552f
commit 007ae7028d
3 files changed
+96 -16

No files matched your search

@@ -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.
@@ -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'
);
});
@@ -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<XtreamCatchupVariant> {
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);