mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(xtream): resolve catchup timeshift variants
This commit is contained in:
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 {
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user