fix(xtream): detect HTTP portals during explicit connection tests (#1588)

This commit is contained in:
4gray authored and GitHub committed 2026-09-12 15:30:22 +02:00
1 parent 0700ba966e
commit 7d1265d566
52 files changed
+2185 -138

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: xtream
---
The explicit HTTPS and HTTP connection test now detects Xtream portals that accept HTTP when HTTPS is unavailable and fills in the working address before you save. The saved address is used for catalog updates, provider EPG and playback. Connection failures now show a more specific explanation.
+12
View File
@@ -341,6 +341,18 @@ there is no browse return action. Contracts:
`docs/architecture/iptvnator-ui-guidelines.md` and
`docs/architecture/portal-detail-navigation.md`.
## Xtream Connection Test
Add/Edit source Test HTTPS and HTTP discloses plaintext credential use before
the click and can replace an unavailable HTTPS base with a
verified active HTTP base in the form. Only initial refused-port or TLS
wrong-version evidence permits the same-host attempt; HTTP errors, certificate
failures and redirect failures do not. Add/Save persists `serverUrl`, and the
routed session observes the metadata change. Passive checks never change the
protocol. Separate XMLTV and already-issued media/download URLs stay independent.
Contract: `docs/architecture/xtream-portal-compatibility.md`
("Explicit protocol discovery").
## Xtream Live Auto Format
The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit
+12
View File
@@ -809,6 +809,18 @@ categories by provider ID and type. See `docs/architecture/category-management.m
### Key Features
#### Xtream Connection Test
Add/Edit source Test HTTPS and HTTP discloses plaintext credential use before
the click and can replace an unavailable HTTPS base with a
verified active HTTP base in the form. Only initial refused-port or TLS
wrong-version evidence permits the same-host attempt; HTTP errors, certificate
failures and redirect failures do not. Add/Save persists `serverUrl`, and the
routed session observes the metadata change. Passive checks never change the
protocol. Separate XMLTV and already-issued media/download URLs stay independent.
Contract: `docs/architecture/xtream-portal-compatibility.md`
("Explicit protocol discovery").
#### Xtream Live Auto Format
The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit
+164 -8
View File
@@ -15,6 +15,7 @@ import {
importM3uPlaylistFromUrl,
launchElectronApp,
openSources,
openAddPlaylistDialog,
openSourceEditor,
refreshSource,
resetMockServers,
@@ -32,6 +33,13 @@ import {
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import {
configureLiveFormat,
expectLiveFormatPlaying,
liveChannels,
liveFormatMock,
} from './xtream-live-format.fixture';
const localSourceFileName = 'alpha-local-source.m3u';
const localSourceDisplayName = 'alpha-local-source';
const urlSourceFileName = 'omega-url-source.m3u';
@@ -42,6 +50,160 @@ const deletableLocalSourceDisplayName = 'deletable-local-source';
const refreshLocalSourceDisplayName = 'refresh-local-source';
test.describe('Electron Sources View', () => {
test('detects HTTP on Test connection and persists it for refresh, EPG and playback', async ({
dataDir,
request,
}) => {
test.setTimeout(150_000);
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir, {
args: ['--autoplay-policy=no-user-gesture-required'],
});
const title = 'HTTP protocol discovery';
const httpsUrl = liveFormatMock.replace('http:', 'https:');
try {
let page = app.mainWindow;
await configureLiveFormat(page, 'html5', 'ts');
await openAddPlaylistDialog(page);
let dialog = page.getByRole('dialog');
await dialog
.getByRole('radio', { name: /Xtream credentials/i })
.click();
await dialog.locator('#title').fill(title);
await dialog.locator('#serverUrl').fill(httpsUrl);
await dialog.locator('#username').fill('live-fallback');
await dialog.locator('#password').fill('live-fallback');
await dialog
.getByRole('button', {
name: 'Test HTTPS and HTTP',
exact: true,
})
.click();
await expect(dialog.getByRole('status')).toContainText(
'Connected using HTTP'
);
await expect(dialog.locator('#serverUrl')).toHaveValue(
liveFormatMock
);
await dialog.screenshot({
path: test.info().outputPath('http-connection-add.png'),
});
await dialog
.getByRole('button', { name: 'Add', exact: true })
.click();
await page.waitForURL(/xtreams.*vod/);
await openSources(page);
dialog = await openSourceEditor(page, title);
await dialog.locator('[formControlName="password"]').fill('');
await dialog
.getByRole('button', {
name: 'Test HTTPS and HTTP',
exact: true,
})
.click();
await expect(dialog.getByRole('status')).toContainText(
'Enter a username and password'
);
await dialog
.locator('[formControlName="password"]')
.fill('live-fallback');
// Closing a tested edit must not persist unrelated changes.
await updateSourceDialog(dialog, {
title: 'Discard this edit',
serverUrl: httpsUrl,
});
await dialog
.getByRole('button', {
name: 'Test HTTPS and HTTP',
exact: true,
})
.click();
await expect(dialog.getByRole('status')).toContainText(
'Connected using HTTP'
);
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
await dialog.waitFor({ state: 'detached' });
dialog = await openSourceEditor(page, title);
await expectSourceDialogValues(dialog, {
title,
serverUrl: liveFormatMock,
});
// Persist a broken HTTPS source, then repair it through Edit.
await updateSourceDialog(dialog, { serverUrl: httpsUrl });
await saveSourceDialog(page, dialog);
dialog = await openSourceEditor(page, title);
await expectSourceDialogValues(dialog, { serverUrl: httpsUrl });
await dialog
.getByRole('button', {
name: 'Test HTTPS and HTTP',
exact: true,
})
.click();
await expect(dialog.getByRole('status')).toContainText(
'Connected using HTTP'
);
await dialog.screenshot({
path: test.info().outputPath('http-connection-edit.png'),
});
await saveSourceDialog(page, dialog);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
page = app.mainWindow;
await openSources(page);
dialog = await openSourceEditor(page, title);
await expectSourceDialogValues(dialog, {
serverUrl: liveFormatMock,
});
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
await dialog.waitFor({ state: 'detached' });
const saved = await page.evaluate(
async (title) =>
(await window.electron.dbGetAppPlaylistMetas()).find(
(p) => p.title === title
),
title
);
expect(saved?.serverUrl).toBe(liveFormatMock);
await refreshSource(page, title, { confirm: true });
await waitForSourceRowIdle(page, title);
const refreshed = await waitForPortalDebugEvent(page, {
provider: 'xtream',
operation: 'get_live_streams',
});
expect(refreshed.request).toMatchObject({
url: expect.stringMatching(/^http:/),
});
await openSources(page);
await sourceRowByTitle(page, title).first().click();
await page
.getByRole('link', { name: 'Live TV', exact: true })
.click();
await page.locator('.context-panel .category-item').first().click();
const media = page.waitForResponse(
(r) =>
r.url().startsWith(liveFormatMock + '/live/') &&
r.url().endsWith('.ts')
);
await liveChannels(page).first().click();
expect((await media).status()).toBe(200);
await expectLiveFormatPlaying(page, 'html5');
const epg = await waitForPortalDebugEvent(page, {
provider: 'xtream',
operation: 'get_short_epg',
});
expect(epg.request).toMatchObject({
url: expect.stringMatching(/^http:/),
});
} finally {
await closeElectronApp(app);
}
});
test('filters and sorts sources, including persisted custom order', async ({
dataDir,
request,
@@ -427,10 +589,7 @@ https://streams.example.test/original-url.m3u8
const app = await launchElectronApp(dataDir);
try {
await dropM3uPlaylistOntoWorkspace(
app.mainWindow,
localFilePath
);
await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath);
await waitForM3uCatalog(app.mainWindow);
await expect(
app.mainWindow.locator(
@@ -467,10 +626,7 @@ https://streams.example.test/original-url.m3u8
});
await openSources(app.mainWindow);
await expect(
sourceRowByTitle(
app.mainWindow,
refreshLocalSourceDisplayName
)
sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName)
.first()
.locator('.refresh-btn')
).toBeVisible();
@@ -214,6 +214,26 @@ for (const theme of ['light', 'dark']) {
await expect
.poll(() => shell.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
// PageDown animates on macOS. Sending Home after the first
// changed frame races that animation rather than testing a
// second discrete keyboard action (same fence as web E2E).
await shell.evaluate(
(element) =>
new Promise<void>((resolve) => {
let last = element.scrollTop;
let stableFrames = 0;
const frame = () => {
stableFrames =
element.scrollTop === last
? stableFrames + 1
: 0;
last = element.scrollTop;
if (stableFrames === 3) resolve();
else requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
})
);
await page.keyboard.press('Home');
await expect
.poll(() => shell.evaluate((el) => el.scrollTop))
@@ -725,6 +725,7 @@ const electronApi: ElectronBridgeApi = {
resetHostConnectivityGuard: (url: string) =>
ipcRenderer.invoke('CONNECTIVITY_GUARD_RESET', { url }),
xtreamRequest: (payload: {
connectionTest?: boolean;
url: string;
params: Record<string, string>;
requestId?: string;
@@ -1,3 +1,4 @@
/// <reference lib="es2021.promise" />
import {
CONNECTIVITY_GUARD_RESET,
XTREAM_CANCEL_SESSION,
@@ -67,6 +68,117 @@ describe('XtreamEvents session cancellation', () => {
}
});
it.each([false, true])(
'returns structured refused-port evidence (redirected=%s)',
async (redirected) => {
if (redirected)
axiosMock.mockResolvedValueOnce({
status: 302,
headers: {
location: 'https://other.example/player_api.php',
},
});
axiosMock.mockRejectedValueOnce(
Object.assign(new Error('private credentials'), {
code: 'ECONNREFUSED',
})
);
const result = await registeredHandlers.get('XTREAM_REQUEST')?.(
{},
{
url: 'https://example.com',
params: { username: 'user', password: 'secret' },
connectionTest: true,
}
);
expect(result).toMatchObject({
connectionFailure: {
kind: 'connection',
canTryHttp: !redirected,
},
});
expect(JSON.stringify(result)).not.toContain('secret');
expect(JSON.stringify(result)).not.toContain('private credentials');
}
);
it.each(['ECONNREFUSED', 'ETIMEDOUT', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'])(
'classifies native aggregate failures before IPC (%s)',
async (code) => {
const failure = Object.assign(
new AggregateError([
Object.assign(new Error('private address'), {
code: 'ECONNREFUSED',
}),
Object.assign(new Error('other private address'), { code }),
]),
{ code: 'ECONNREFUSED' }
);
axiosMock.mockRejectedValueOnce(failure);
const result = await registeredHandlers.get('XTREAM_REQUEST')?.(
{},
{
url: 'https://example.com',
params: {},
connectionTest: true,
}
);
expect(result).toMatchObject({
connectionFailure: {
kind:
code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
? 'tls'
: 'connection',
canTryHttp: code === 'ECONNREFUSED',
},
});
expect(JSON.stringify(result)).not.toContain('private address');
}
);
it.each<[string | undefined, number]>([
[undefined, 1],
['file:///private/secret', 1],
['http://[', 1],
['https://example.com/player_api.php', 6],
])(
'keeps local redirect failure %s out of provider HTTP evidence',
async (location, requests) => {
axiosMock.mockResolvedValue({ status: 302, headers: { location } });
const result = await registeredHandlers.get('XTREAM_REQUEST')?.(
{},
{
url: 'https://example.com',
params: {},
connectionTest: true,
}
);
expect(result).toHaveProperty('connectionFailure', {
kind: 'connection',
canTryHttp: false,
});
expect(axiosMock).toHaveBeenCalledTimes(requests);
expect(JSON.stringify(result)).not.toContain('secret');
}
);
it('returns the provider HTTP error without enabling fallback', async () => {
axiosMock.mockResolvedValueOnce({
status: 403,
statusText: 'Forbidden',
headers: {},
data: '<html>blocked</html>',
});
expect(
await registeredHandlers.get('XTREAM_REQUEST')?.(
{},
{ url: 'https://example.com', params: {}, connectionTest: true }
)
).toMatchObject({
connectionFailure: { kind: 'http', status: 403, canTryHttp: false },
});
});
it('normalizes full Xtream API URLs before appending player_api.php', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
expect(requestHandler).toBeDefined();
@@ -3,7 +3,7 @@
* between the frontend and the electron backend.
*/
import axios, { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import { ipcMain } from 'electron';
import {
PortalDebugEvent,
@@ -11,11 +11,16 @@ import {
XTREAM_CLIENT_USER_AGENT,
XTREAM_MAIN_PERFORMANCE_PHASE,
normalizeXtreamServerUrl,
describeXtreamConnectionFailure,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { emitPortalDebugEvent } from './portal-debug.events';
import { formatPortalRequestError } from './portal-request-error.util';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import { UnsafeUrlError } from './url-safety';
import {
requestWithValidatedRedirects,
ValidatedAxiosRequestConfig,
} from '../util/validated-axios';
import {
HostConnectivityGuardError,
HostRequestToken,
@@ -62,12 +67,14 @@ ipcMain.handle(
requestId?: string;
sessionId?: string;
suppressErrorLog?: boolean;
connectionTest?: boolean;
}
) => {
const startedAt = Date.now();
const performanceCapture = createXtreamMainPerformanceCaptureForRequest(
payload.requestId
);
let initialResponded = false;
let activeRequestKey: string | null = null;
let requestUrlForLog = payload.url;
let guardToken: HostRequestToken | null = null;
@@ -93,7 +100,10 @@ ipcMain.handle(
}
// Configure axios request
const config: AxiosRequestConfig = {
const config: ValidatedAxiosRequestConfig = {
onResponse: () => {
initialResponded = true;
},
method: 'GET',
url: apiUrl.toString(),
headers: {
@@ -209,6 +219,23 @@ ipcMain.handle(
emitPortalDebugEvent(debugEvent);
}
if (payload.connectionTest) {
reportGuardedHostFailure(guardToken, error, {
requestUrl: requestUrlForLog,
});
return {
payload: null,
action: payload.params?.action,
connectionFailure:
error instanceof UnsafeUrlError
? { kind: 'connection', canTryHttp: false }
: describeXtreamConnectionFailure(
error,
initialResponded
),
};
}
if (error instanceof HostConnectivityGuardError) {
// The failures that tripped the guard were logged when they
// happened; a line per skipped request would be the very log
@@ -30,6 +30,7 @@ export type ValidatedAxiosRequestConfig = Omit<
'httpAgent' | 'httpsAgent'
> & {
agentFactory?: ValidatedRequestAgentFactory;
onResponse?: () => void;
};
function copyHeadersWithoutSensitiveValues(
@@ -166,7 +167,7 @@ function getRedirectValidationPolicy(
*/
export async function requestWithValidatedRedirects<T = unknown>(
rawUrl: string,
config: ValidatedAxiosRequestConfig = {},
{ onResponse, ...config }: ValidatedAxiosRequestConfig = {},
policy: RemoteUrlPolicy = {},
maxRedirects = 5
): Promise<AxiosResponse<T>> {
@@ -209,6 +210,7 @@ export async function requestWithValidatedRedirects<T = unknown>(
REDIRECT_STATUSES.has(status) || originalValidateStatus(status),
});
onResponse?.();
if (!REDIRECT_STATUSES.has(response.status)) {
return response;
}
@@ -236,3 +236,94 @@ describe('redirect routes and admission lifecycle', () => {
}
);
});
describe('Xtream explicit protocol-test evidence', () => {
it.each(['dns', 'private redirect', 'redirect cycle', 'provider HTTP'])(
'separates %s failures from provider response statuses',
async (mode) => {
const transport = new StubHttpClient();
let failDns = false;
await withServer(
createWebBackendApp({
httpClient: transport,
allowPrivateNetworkTargets: false,
resolveHostname: async () => {
if (failDns)
throw Object.assign(new Error('private details'), {
code: 'ENOTFOUND',
});
return ['93.184.216.34'];
},
}),
async (backend) => {
const id = await registerProviderTarget(
backend,
'https://provider.example'
);
if (mode === 'dns') failDns = true;
else if (mode === 'private redirect')
transport.queueRedirect('http://127.0.0.1/private');
else if (mode === 'redirect cycle')
transport.queueRedirect(
'https://provider.example/player_api.php'
);
else transport.queueFailure(500);
const response = await fetch(
`${backend}/xtream?targetId=${id}&connectionTest=true`
);
expect(await response.json()).toEqual({
connectionFailure:
mode === 'provider HTTP'
? {
kind: 'http',
status: 500,
canTryHttp: false,
}
: { kind: 'connection', canTryHttp: false },
});
expect(transport.requests).toHaveLength(
mode === 'dns' ? 0 : 1
);
}
);
}
);
it.each([false, true])(
'preserves initial-response evidence (redirected=%s)',
async (redirected) => {
const transport = new StubHttpClient();
if (redirected)
transport.queueRedirect('https://other.example/player_api.php');
transport.queueNetworkError(
Object.assign(new Error('private provider text'), {
code: 'ECONNREFUSED',
})
);
await withServer(
createWebBackendApp({
httpClient: transport,
resolveHostname: resolvePublicHost,
}),
async (backend) => {
const id = await registerProviderTarget(
backend,
'https://provider.example'
);
const response = await fetch(
`${backend}/xtream?targetId=${id}&connectionTest=true&username=user&password=secret`
);
expect(await response.json()).toEqual({
connectionFailure: {
kind: 'connection',
canTryHttp: !redirected,
},
});
expect(transport.requests[0].params).not.toHaveProperty(
'connectionTest'
);
}
);
}
);
});
+16 -2
View File
@@ -12,6 +12,7 @@ import {
buildStalkerIdentityRequestContext,
buildStalkerRequestUrl,
normalizeXtreamServerUrl,
describeXtreamConnectionFailure,
} from '@iptvnator/shared/interfaces';
import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils';
import {
@@ -281,7 +282,7 @@ export function createWebBackendApp(
requestUrl = appendPathSegment(url, 'player_api.php');
const response = await httpClient.get(requestUrl, {
params: getProxyParams(req, ['targetId']),
params: getProxyParams(req, ['targetId', 'connectionTest']),
timeout: PROVIDER_REQUEST_TIMEOUT_MS.xtream,
});
reportProviderRequestSuccess(hostGuard, guardToken);
@@ -296,7 +297,20 @@ export function createWebBackendApp(
requestUrl,
});
logProviderRequestFailure({ error, route: '/xtream', url });
res.json(normalizeProviderError(error));
if (getQueryString(req, 'connectionTest') === 'true') {
const wrapped =
error instanceof ProviderRequestError ? error : null;
res.json({
connectionFailure: wrapped?.policyError
? { kind: 'connection', canTryHttp: false }
: describeXtreamConnectionFailure(
wrapped?.cause ?? error,
wrapped?.initialResponded ?? true
),
});
} else {
res.json(normalizeProviderError(error));
}
} finally {
releaseProviderRequest(hostGuard, guardToken);
}
+81
View File
@@ -0,0 +1,81 @@
import { expect, test } from './fixtures';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
} from './provider-target-route';
for (const canTryHttp of [true, false]) {
test(`@xtream connection test respects proxy evidence: fallback=${canTryHttp}`, async ({
page,
}) => {
await page.goto('/');
const targets = await interceptProviderTargetRegistration(page);
const requested: string[] = [];
await page.route(
'**/localhost:3000/connectivity-guard/reset',
(route) => route.fulfill({ json: { reset: true } })
);
await page.route('**/localhost:3000/xtream**', async (route) => {
const url = new URL(route.request().url());
const base = getRegisteredProviderUrl(url, targets);
if (!base) throw new Error('Missing synthetic provider target');
requested.push(base);
expect(url.searchParams.get('connectionTest')).toBe('true');
await route.fulfill({
json: base.startsWith('https:')
? {
connectionFailure: {
kind: canTryHttp ? 'connection' : 'tls',
canTryHttp,
},
}
: { payload: { user_info: { auth: 1, status: 'Active' } } },
});
});
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.getByRole('dialog');
await dialog
.getByRole('radio', { name: /Xtream credentials/i })
.click();
await dialog.locator('#title').fill('Synthetic protocol test');
await dialog
.locator('#serverUrl')
.fill('https://panel.example/base/get.php?type=m3u');
await dialog.locator('#username').fill(' ');
await dialog.locator('#password').fill('pass');
await dialog
.getByRole('button', { name: 'Test HTTPS and HTTP', exact: true })
.click();
await expect(dialog.getByRole('status')).toContainText(
'Enter a username and password'
);
expect(requested).toEqual([]);
await dialog.locator('#username').fill('user');
await dialog.locator('#password').fill('pass');
await expect(dialog.locator('#xtream-http-test-notice')).toBeVisible();
await expect(dialog.locator('#xtream-http-test-notice')).toContainText(
'username and password'
);
await dialog
.getByRole('button', { name: 'Test HTTPS and HTTP', exact: true })
.click();
await expect(dialog.getByRole('status')).toContainText(
canTryHttp
? 'Connected using HTTP'
: 'Could not establish a secure connection'
);
await expect(dialog.locator('#serverUrl')).toHaveValue(
canTryHttp
? 'http://panel.example/base'
: 'https://panel.example/base/get.php?type=m3u'
);
expect(requested).toEqual(
canTryHttp
? ['https://panel.example/base', 'http://panel.example/base']
: ['https://panel.example/base']
);
await expect(
dialog.getByRole('button', { name: 'Add', exact: true })
).toBeEnabled();
});
}
@@ -13,6 +13,7 @@ import {
AutoUpdatePlaylistsResult,
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
PLAYLIST_PARSE_BY_URL,
XTREAM_REQUEST,
Playlist,
SECURITY_ERROR_PREFIX,
} from '@iptvnator/shared/interfaces';
@@ -21,6 +22,7 @@ import { ElectronService } from './electron.service';
describe('ElectronService', () => {
const session = { id: 'session-1' };
let electronBridge: {
xtreamRequest: jest.Mock;
autoUpdatePlaylists: jest.Mock;
fetchPlaylistByUrl: jest.Mock;
onPlayerError: jest.Mock;
@@ -38,6 +40,7 @@ describe('ElectronService', () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
electronBridge = {
xtreamRequest: jest.fn(),
autoUpdatePlaylists: jest.fn(),
fetchPlaylistByUrl: jest.fn(),
onPlayerError: jest.fn(),
@@ -105,6 +108,23 @@ describe('ElectronService', () => {
jest.restoreAllMocks();
});
it('preserves connection-test errors across the renderer bridge', async () => {
const response = {
connectionFailure: { kind: 'tls', canTryHttp: false },
};
electronBridge.xtreamRequest.mockResolvedValue(response);
const result = await service.sendIpcEvent(XTREAM_REQUEST, {
url: 'https://provider.example',
params: {},
connectionTest: true,
});
expect(result).toEqual(response);
expect(electronBridge.xtreamRequest).toHaveBeenCalledWith(
expect.objectContaining({ connectionTest: true })
);
expect(snackBar.open).not.toHaveBeenCalled();
});
it('ignores URL imports without a payload instead of calling the Electron bridge', async () => {
await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL);
@@ -605,6 +605,7 @@ export class ElectronService extends DataService {
} */
private async forwardXtreamRequest(payload: {
connectionTest?: boolean;
url: string;
params: Record<string, string>;
requestId?: string;
@@ -625,6 +626,8 @@ export class ElectronService extends DataService {
requestId: context.requestId,
});
if (payload.connectionTest) return response;
const result = {
type: XTREAM_RESPONSE,
payload: response.payload,
@@ -14,6 +14,7 @@ import {
PLAYLIST_PARSE_BY_URL,
PLAYLIST_UPDATE,
STALKER_REQUEST,
XTREAM_REQUEST,
} from '@iptvnator/shared/interfaces';
import {
getStalkerRequestErrorStatus,
@@ -68,6 +69,26 @@ describe('PwaService', () => {
jest.restoreAllMocks();
});
it('passes explicit connection-test evidence through without a playback response', async () => {
const failure = {
connectionFailure: { kind: 'connection', canTryHttp: true },
};
const pending = service.sendIpcEvent(XTREAM_REQUEST, {
url: 'https://provider.example',
params: { username: 'user', password: 'pass' },
connectionTest: true,
});
http.expectOne((req) => req.url.endsWith('/provider-targets')).flush({
targetId: 'test-target',
});
await new Promise((resolve) => setTimeout(resolve));
const request = http.expectOne((req) => req.url.endsWith('/xtream'));
expect(request.request.params.get('connectionTest')).toBe('true');
request.flush(failure);
expect(await pending).toEqual(failure);
expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled();
});
it('ignores URL imports without a payload or URL instead of calling the backend', () => {
service.sendIpcEvent(PLAYLIST_PARSE_BY_URL);
service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {});
+4
View File
@@ -372,6 +372,7 @@ export class PwaService extends DataService {
}
async forwardXtreamRequest(payload: {
connectionTest?: boolean;
url: string;
params: Record<string, string>;
macAddress?: string;
@@ -402,6 +403,7 @@ export class PwaService extends DataService {
const requestParams = {
targetId,
...payload.params,
...(payload.connectionTest ? { connectionTest: 'true' } : {}),
};
const requestPayload = {
method: 'GET',
@@ -434,6 +436,8 @@ export class PwaService extends DataService {
)
)) as PwaXtreamResponse;
if (payload.connectionTest) return response;
if (!response.payload) {
const action = payload.params.action;
const isSilentAction =
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "كلمة المرور",
"ADD": "إضافة",
"TEST_CREDENTIALS": "اختبار بيانات الاعتماد",
"URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com)"
"URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "أدخل اسم المستخدم وكلمة المرور لاختبار الاتصال.",
"TESTING": "جارٍ اختبار الاتصال…",
"HTTP_CONNECTED": "تم الاتصال عبر HTTP. بروتوكول HTTPS غير متاح. الاتصال غير مشفر.",
"ACTIVE": "تم الاتصال. البوابة نشطة.",
"INACTIVE": "الحساب غير نشط أو بيانات الدخول غير صحيحة.",
"EXPIRED": "انتهت صلاحية الاشتراك.",
"UNAVAILABLE": "لم تُرجع البوابة معلومات حساب صالحة.",
"HTTP_ERROR": "أرجع الخادم HTTP {{status}}.",
"TLS_ERROR": "تعذر إنشاء اتصال آمن. تحقق من إعدادات HTTPS وشهادة الخادم.",
"CONNECTION_ERROR": "تعذر الوصول إلى الخادم. تحقق من العنوان واتصال الشبكة.",
"UNKNOWN_ERROR": "تعذر اختبار الاتصال.",
"TEST_PROTOCOLS": "اختبار HTTPS وHTTP",
"HTTP_PERMISSION": "إذا كان HTTPS غير متاح، فقد يرسل هذا الاختبار اسم المستخدم وكلمة المرور عبر HTTP غير المشفر."
}
},
"STALKER_PORTAL": {
"TITLE": "العنوان",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "كلمة المرور",
"ADD": "زيد",
"TEST_CREDENTIALS": "جرب بيانات الاعتماد",
"URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com ولا https://example.com:4324)"
"URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com ولا https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "دخل اسم المستخدم وكلمة السر باش تجرّب الاتصال.",
"TESTING": "كنجرّبو الاتصال…",
"HTTP_CONNECTED": "تّاصلنا بـ HTTP. HTTPS ما متوفرش. الاتصال ما مشفّرش.",
"ACTIVE": "تّاصلنا. البوابة خدامة.",
"INACTIVE": "الحساب ما مفعّلش أو معلومات الدخول غالطة.",
"EXPIRED": "الاشتراك سالا.",
"UNAVAILABLE": "البوابة ما رجّعاتش معلومات حساب صحيحة.",
"HTTP_ERROR": "السيرفر رجّع HTTP {{status}}.",
"TLS_ERROR": "ما قدرناش نديرو اتصال آمن. تأكد من إعدادات HTTPS وشهادة السيرفر.",
"CONNECTION_ERROR": "ما قدرناش نوصلو للسيرفر. تأكد من العنوان والاتصال بالشبكة.",
"UNKNOWN_ERROR": "ما قدرناش نجرّبو الاتصال.",
"TEST_PROTOCOLS": "جرّب HTTPS وHTTP",
"HTTP_PERMISSION": "إلا كان HTTPS ما متوفرش، هاد الاختبار يقدر يصيفط اسم المستخدم وكلمة السر بـ HTTP بلا تشفير."
}
},
"STALKER_PORTAL": {
"TITLE": "العنوان",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Пароль",
"ADD": "Дадаць",
"TEST_CREDENTIALS": "Праверыць",
"URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com або https://example.com:4324)."
"URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com або https://example.com:4324).",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Увядзіце імя карыстальніка і пароль для праверкі злучэння.",
"TESTING": "Праверка падключэння…",
"HTTP_CONNECTED": "Падключана праз HTTP. HTTPS недаступны. Злучэнне не зашыфравана.",
"ACTIVE": "Падключана. Партал актыўны.",
"INACTIVE": "Уліковы запіс неактыўны або даныя ўваходу няправільныя.",
"EXPIRED": "Тэрмін падпіскі скончыўся.",
"UNAVAILABLE": "Партал не вярнуў карэктныя даныя ўліковага запісу.",
"HTTP_ERROR": "Сервер вярнуў HTTP {{status}}.",
"TLS_ERROR": "Не ўдалося ўсталяваць абароненае злучэнне. Праверце налады HTTPS і сертыфікат сервера.",
"CONNECTION_ERROR": "Сервер недаступны. Праверце адрас і падключэнне да сеткі.",
"UNKNOWN_ERROR": "Не ўдалося праверыць падключэнне.",
"TEST_PROTOCOLS": "Праверыць HTTPS і HTTP",
"HTTP_PERMISSION": "Калі HTTPS недаступны, гэтая праверка можа адправіць імя карыстальніка і пароль праз незашыфраваны HTTP."
}
},
"STALKER_PORTAL": {
"TITLE": "Назва",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Passwort",
"ADD": "Hinzufügen",
"TEST_CREDENTIALS": "Verbindung testen",
"URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com oder https://example.com:4324)"
"URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com oder https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Gib einen Benutzernamen und ein Passwort ein, um die Verbindung zu testen.",
"TESTING": "Verbindung wird geprüft…",
"HTTP_CONNECTED": "Über HTTP verbunden. HTTPS ist nicht verfügbar. Die Verbindung ist unverschlüsselt.",
"ACTIVE": "Verbunden. Das Portal ist aktiv.",
"INACTIVE": "Das Konto ist inaktiv oder die Zugangsdaten sind falsch.",
"EXPIRED": "Das Abonnement ist abgelaufen.",
"UNAVAILABLE": "Das Portal hat keine gültigen Kontodaten geliefert.",
"HTTP_ERROR": "Der Server antwortete mit HTTP {{status}}.",
"TLS_ERROR": "Keine sichere Verbindung möglich. Prüfen Sie die HTTPS-Konfiguration und das Zertifikat des Servers.",
"CONNECTION_ERROR": "Server nicht erreichbar. Prüfen Sie die Adresse und Ihre Netzwerkverbindung.",
"UNKNOWN_ERROR": "Verbindung konnte nicht geprüft werden.",
"TEST_PROTOCOLS": "HTTPS und HTTP testen",
"HTTP_PERMISSION": "Wenn HTTPS nicht verfügbar ist, kann dieser Test Ihren Benutzernamen und Ihr Passwort über unverschlüsseltes HTTP senden."
}
},
"STALKER_PORTAL": {
"TITLE": "Titel",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Κωδικός πρόσβασης",
"ADD": "Προσθήκη",
"TEST_CREDENTIALS": "Δοκιμή διαπιστευτήριων",
"URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com ή https://example.com:4324)"
"URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com ή https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Εισαγάγετε όνομα χρήστη και κωδικό πρόσβασης για να ελέγξετε τη σύνδεση.",
"TESTING": "Έλεγχος σύνδεσης…",
"HTTP_CONNECTED": "Σύνδεση μέσω HTTP. Το HTTPS δεν είναι διαθέσιμο. Η σύνδεση δεν είναι κρυπτογραφημένη.",
"ACTIVE": "Συνδέθηκε. Η πύλη είναι ενεργή.",
"INACTIVE": "Ο λογαριασμός είναι ανενεργός ή τα στοιχεία σύνδεσης είναι λανθασμένα.",
"EXPIRED": "Η συνδρομή έχει λήξει.",
"UNAVAILABLE": "Η πύλη δεν επέστρεψε έγκυρα στοιχεία λογαριασμού.",
"HTTP_ERROR": "Ο διακομιστής επέστρεψε HTTP {{status}}.",
"TLS_ERROR": "Δεν ήταν δυνατή η ασφαλής σύνδεση. Ελέγξτε τις ρυθμίσεις HTTPS και το πιστοποιητικό του διακομιστή.",
"CONNECTION_ERROR": "Ο διακομιστής δεν είναι προσβάσιμος. Ελέγξτε τη διεύθυνση και τη σύνδεση δικτύου.",
"UNKNOWN_ERROR": "Δεν ήταν δυνατός ο έλεγχος σύνδεσης.",
"TEST_PROTOCOLS": "Έλεγχος HTTPS και HTTP",
"HTTP_PERMISSION": "Αν το HTTPS δεν είναι διαθέσιμο, αυτός ο έλεγχος μπορεί να στείλει το όνομα χρήστη και τον κωδικό σας μέσω μη κρυπτογραφημένου HTTP."
}
},
"STALKER_PORTAL": {
"TITLE": "Τίτλος",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Password",
"ADD": "Add",
"TEST_CREDENTIALS": "Test credentials",
"URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com or https://example.com:4324)"
"URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com or https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Enter a username and password to test the connection.",
"TESTING": "Testing connection…",
"HTTP_CONNECTED": "Connected using HTTP. HTTPS is unavailable. The connection is not encrypted.",
"ACTIVE": "Connected. The portal is active.",
"INACTIVE": "The account is inactive or the credentials are incorrect.",
"EXPIRED": "The subscription has expired.",
"UNAVAILABLE": "The portal did not return valid account information.",
"HTTP_ERROR": "The server returned HTTP {{status}}.",
"TLS_ERROR": "Could not establish a secure connection. Check the server’s HTTPS configuration and certificate.",
"CONNECTION_ERROR": "Could not reach the server. Check its address and your network connection.",
"UNKNOWN_ERROR": "Could not test the connection.",
"TEST_PROTOCOLS": "Test HTTPS and HTTP",
"HTTP_PERMISSION": "This test may send your username and password over unencrypted HTTP if HTTPS is unavailable."
}
},
"STALKER_PORTAL": {
"TITLE": "Title",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Contraseña",
"ADD": "Agregar",
"TEST_CREDENTIALS": "Probar credenciales",
"URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com o https://example.com:4324)."
"URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com o https://example.com:4324).",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Introduce un nombre de usuario y una contraseña para probar la conexión.",
"TESTING": "Probando conexión…",
"HTTP_CONNECTED": "Conectado por HTTP. HTTPS no está disponible. La conexión no está cifrada.",
"ACTIVE": "Conectado. El portal está activo.",
"INACTIVE": "La cuenta está inactiva o las credenciales son incorrectas.",
"EXPIRED": "La suscripción ha caducado.",
"UNAVAILABLE": "El portal no devolvió información válida de la cuenta.",
"HTTP_ERROR": "El servidor devolvió HTTP {{status}}.",
"TLS_ERROR": "No se pudo establecer una conexión segura. Comprueba la configuración HTTPS y el certificado del servidor.",
"CONNECTION_ERROR": "No se pudo acceder al servidor. Comprueba la dirección y la conexión de red.",
"UNKNOWN_ERROR": "No se pudo probar la conexión.",
"TEST_PROTOCOLS": "Probar HTTPS y HTTP",
"HTTP_PERMISSION": "Si HTTPS no está disponible, esta prueba puede enviar tu usuario y contraseña por HTTP sin cifrar."
}
},
"STALKER_PORTAL": {
"TITLE": "Título",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Mot de passe",
"ADD": "Ajouter",
"TEST_CREDENTIALS": "Tester les identifiants",
"URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com ou https://example.com:4324)"
"URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com ou https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Saisissez un nom d’utilisateur et un mot de passe pour tester la connexion.",
"TESTING": "Test de la connexion…",
"HTTP_CONNECTED": "Connecté via HTTP. HTTPS est indisponible. La connexion n’est pas chiffrée.",
"ACTIVE": "Connecté. Le portail est actif.",
"INACTIVE": "Le compte est inactif ou les identifiants sont incorrects.",
"EXPIRED": "L’abonnement a expiré.",
"UNAVAILABLE": "Le portail n’a pas renvoyé de données de compte valides.",
"HTTP_ERROR": "Le serveur a renvoyé HTTP {{status}}.",
"TLS_ERROR": "Impossible d’établir une connexion sécurisée. Vérifiez la configuration HTTPS et le certificat du serveur.",
"CONNECTION_ERROR": "Serveur inaccessible. Vérifiez son adresse et votre connexion réseau.",
"UNKNOWN_ERROR": "Impossible de tester la connexion.",
"TEST_PROTOCOLS": "Tester HTTPS et HTTP",
"HTTP_PERMISSION": "Si HTTPS est indisponible, ce test peut envoyer votre identifiant et votre mot de passe via HTTP non chiffré."
}
},
"STALKER_PORTAL": {
"TITLE": "Titre",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Jelszó",
"ADD": "Hozzáadás",
"TEST_CREDENTIALS": "Hitelesítési adatok ellenőrzése",
"URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com vagy https://example.com:4324)"
"URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com vagy https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "A kapcsolat teszteléséhez adjon meg egy felhasználónevet és jelszót.",
"TESTING": "Kapcsolat tesztelése…",
"HTTP_CONNECTED": "Csatlakoztatva HTTP-n keresztül. A HTTPS nem érhető el. A kapcsolat nem titkosított.",
"ACTIVE": "Csatlakoztatva. A portál aktív.",
"INACTIVE": "A fiók inaktív vagy a bejelentkezési adatok hibásak.",
"EXPIRED": "Az előfizetés lejárt.",
"UNAVAILABLE": "A portál nem adott vissza érvényes fiókadatokat.",
"HTTP_ERROR": "A szerver HTTP {{status}} választ adott.",
"TLS_ERROR": "Nem hozható létre biztonságos kapcsolat. Ellenőrizze a szerver HTTPS-beállításait és tanúsítványát.",
"CONNECTION_ERROR": "A szerver nem érhető el. Ellenőrizze a címet és a hálózati kapcsolatot.",
"UNKNOWN_ERROR": "A kapcsolat nem tesztelhető.",
"TEST_PROTOCOLS": "HTTPS és HTTP tesztelése",
"HTTP_PERMISSION": "Ha a HTTPS nem érhető el, ez a teszt titkosítatlan HTTP-n küldheti el a felhasználónevet és a jelszót."
}
},
"STALKER_PORTAL": {
"TITLE": "Cím",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Password",
"ADD": "Aggiungi",
"TEST_CREDENTIALS": "Testa credenziali",
"URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it o https://esempio.it:4324)"
"URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it o https://esempio.it:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Inserisci nome utente e password per verificare la connessione.",
"TESTING": "Verifica della connessione…",
"HTTP_CONNECTED": "Connesso tramite HTTP. HTTPS non è disponibile. La connessione non è crittografata.",
"ACTIVE": "Connesso. Il portale è attivo.",
"INACTIVE": "L’account è inattivo o le credenziali sono errate.",
"EXPIRED": "L’abbonamento è scaduto.",
"UNAVAILABLE": "Il portale non ha restituito dati validi dell’account.",
"HTTP_ERROR": "Il server ha restituito HTTP {{status}}.",
"TLS_ERROR": "Impossibile stabilire una connessione sicura. Verifica la configurazione HTTPS e il certificato del server.",
"CONNECTION_ERROR": "Server non raggiungibile. Verifica l’indirizzo e la connessione di rete.",
"UNKNOWN_ERROR": "Impossibile verificare la connessione.",
"TEST_PROTOCOLS": "Verifica HTTPS e HTTP",
"HTTP_PERMISSION": "Se HTTPS non è disponibile, questo test può inviare nome utente e password tramite HTTP non crittografato."
}
},
"STALKER_PORTAL": {
"TITLE": "Titolo",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "パスワード",
"ADD": "追加",
"TEST_CREDENTIALS": "認証情報をテスト",
"URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com または https://example.com:4324)"
"URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com または https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "接続をテストするには、ユーザー名とパスワードを入力してください。",
"TESTING": "接続をテスト中…",
"HTTP_CONNECTED": "HTTPで接続しました。HTTPSは利用できません。接続は暗号化されていません。",
"ACTIVE": "接続しました。ポータルは有効です。",
"INACTIVE": "アカウントが無効か、認証情報が正しくありません。",
"EXPIRED": "サブスクリプションの有効期限が切れています。",
"UNAVAILABLE": "ポータルから有効なアカウント情報が返されませんでした。",
"HTTP_ERROR": "サーバーがHTTP {{status}}を返しました。",
"TLS_ERROR": "安全な接続を確立できませんでした。サーバーのHTTPS設定と証明書を確認してください。",
"CONNECTION_ERROR": "サーバーに接続できません。アドレスとネットワーク接続を確認してください。",
"UNKNOWN_ERROR": "接続をテストできませんでした。",
"TEST_PROTOCOLS": "HTTPSとHTTPをテスト",
"HTTP_PERMISSION": "HTTPSが利用できない場合、このテストではユーザー名とパスワードが暗号化されていないHTTPで送信されることがあります。"
}
},
"STALKER_PORTAL": {
"TITLE": "タイトル",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "비밀번호",
"ADD": "추가",
"TEST_CREDENTIALS": "자격 증명 테스트",
"URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com 또는 https://example.com:4324)."
"URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com 또는 https://example.com:4324).",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "연결을 테스트하려면 사용자 이름과 비밀번호를 입력하세요.",
"TESTING": "연결 테스트 중…",
"HTTP_CONNECTED": "HTTP로 연결되었습니다. HTTPS를 사용할 수 없습니다. 연결이 암호화되지 않습니다.",
"ACTIVE": "연결되었습니다. 포털이 활성 상태입니다.",
"INACTIVE": "계정이 비활성 상태이거나 로그인 정보가 잘못되었습니다.",
"EXPIRED": "구독이 만료되었습니다.",
"UNAVAILABLE": "포털에서 유효한 계정 정보를 반환하지 않았습니다.",
"HTTP_ERROR": "서버에서 HTTP {{status}}를 반환했습니다.",
"TLS_ERROR": "보안 연결을 설정할 수 없습니다. 서버의 HTTPS 설정과 인증서를 확인하세요.",
"CONNECTION_ERROR": "서버에 연결할 수 없습니다. 주소와 네트워크 연결을 확인하세요.",
"UNKNOWN_ERROR": "연결을 테스트할 수 없습니다.",
"TEST_PROTOCOLS": "HTTPS 및 HTTP 테스트",
"HTTP_PERMISSION": "HTTPS를 사용할 수 없는 경우 이 테스트는 사용자 이름과 비밀번호를 암호화되지 않은 HTTP로 전송할 수 있습니다."
}
},
"STALKER_PORTAL": {
"TITLE": "제목",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Wachtwoord",
"ADD": "Toevoegen",
"TEST_CREDENTIALS": "Test de inloggegevens",
"URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com of https://example.com:4324)"
"URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com of https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Voer een gebruikersnaam en wachtwoord in om de verbinding te testen.",
"TESTING": "Verbinding testen…",
"HTTP_CONNECTED": "Verbonden via HTTP. HTTPS is niet beschikbaar. De verbinding is niet versleuteld.",
"ACTIVE": "Verbonden. Het portaal is actief.",
"INACTIVE": "Het account is inactief of de inloggegevens zijn onjuist.",
"EXPIRED": "Het abonnement is verlopen.",
"UNAVAILABLE": "Het portaal gaf geen geldige accountgegevens terug.",
"HTTP_ERROR": "De server gaf HTTP {{status}} terug.",
"TLS_ERROR": "Kan geen beveiligde verbinding maken. Controleer de HTTPS-configuratie en het certificaat van de server.",
"CONNECTION_ERROR": "Server niet bereikbaar. Controleer het adres en uw netwerkverbinding.",
"UNKNOWN_ERROR": "Kan de verbinding niet testen.",
"TEST_PROTOCOLS": "HTTPS en HTTP testen",
"HTTP_PERMISSION": "Als HTTPS niet beschikbaar is, kan deze test uw gebruikersnaam en wachtwoord via onversleuteld HTTP verzenden."
}
},
"STALKER_PORTAL": {
"TITLE": "Titel",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Hasło",
"ADD": "Dodaj",
"TEST_CREDENTIALS": "Testuj dane uwierzytelniające",
"URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com lub https://example.com:4324)"
"URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com lub https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Wpisz nazwę użytkownika i hasło, aby sprawdzić połączenie.",
"TESTING": "Sprawdzanie połączenia…",
"HTTP_CONNECTED": "Połączono przez HTTP. HTTPS jest niedostępny. Połączenie nie jest szyfrowane.",
"ACTIVE": "Połączono. Portal jest aktywny.",
"INACTIVE": "Konto jest nieaktywne lub dane logowania są nieprawidłowe.",
"EXPIRED": "Subskrypcja wygasła.",
"UNAVAILABLE": "Portal nie zwrócił prawidłowych danych konta.",
"HTTP_ERROR": "Serwer zwrócił HTTP {{status}}.",
"TLS_ERROR": "Nie można nawiązać bezpiecznego połączenia. Sprawdź konfigurację HTTPS i certyfikat serwera.",
"CONNECTION_ERROR": "Serwer jest nieosiągalny. Sprawdź adres i połączenie sieciowe.",
"UNKNOWN_ERROR": "Nie można sprawdzić połączenia.",
"TEST_PROTOCOLS": "Sprawdź HTTPS i HTTP",
"HTTP_PERMISSION": "Jeśli HTTPS jest niedostępny, ten test może wysłać nazwę użytkownika i hasło przez nieszyfrowane HTTP."
}
},
"STALKER_PORTAL": {
"TITLE": "Tytuł",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Senha",
"ADD": "Adicionar",
"TEST_CREDENTIALS": "Testar credenciais",
"URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com ou https://example.com:4324)"
"URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com ou https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Introduza um nome de utilizador e uma palavra-passe para testar a ligação.",
"TESTING": "A testar ligação…",
"HTTP_CONNECTED": "Ligado por HTTP. HTTPS indisponível. A ligação não está encriptada.",
"ACTIVE": "Ligado. O portal está ativo.",
"INACTIVE": "A conta está inativa ou as credenciais estão incorretas.",
"EXPIRED": "A subscrição expirou.",
"UNAVAILABLE": "O portal não devolveu dados válidos da conta.",
"HTTP_ERROR": "O servidor devolveu HTTP {{status}}.",
"TLS_ERROR": "Não foi possível estabelecer uma ligação segura. Verifique a configuração HTTPS e o certificado do servidor.",
"CONNECTION_ERROR": "Servidor inacessível. Verifique o endereço e a ligação à rede.",
"UNKNOWN_ERROR": "Não foi possível testar a ligação.",
"TEST_PROTOCOLS": "Testar HTTPS e HTTP",
"HTTP_PERMISSION": "Se HTTPS não estiver disponível, este teste pode enviar o nome de utilizador e a palavra-passe por HTTP sem encriptação."
}
},
"STALKER_PORTAL": {
"TITLE": "Título",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Пароль",
"ADD": "Добавить",
"TEST_CREDENTIALS": "Проверить",
"URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com или https://example.com:4324)."
"URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com или https://example.com:4324).",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Введите имя пользователя и пароль для проверки подключения.",
"TESTING": "Проверка подключения…",
"HTTP_CONNECTED": "Подключено через HTTP. HTTPS недоступен. Соединение не зашифровано.",
"ACTIVE": "Подключено. Портал активен.",
"INACTIVE": "Аккаунт неактивен или неверно указаны логин и пароль.",
"EXPIRED": "Срок подписки истёк.",
"UNAVAILABLE": "Портал не вернул корректные данные аккаунта.",
"HTTP_ERROR": "Сервер вернул HTTP {{status}}.",
"TLS_ERROR": "Не удалось установить защищённое соединение. Проверьте настройки HTTPS и сертификат сервера.",
"CONNECTION_ERROR": "Сервер недоступен. Проверьте адрес и подключение к сети.",
"UNKNOWN_ERROR": "Не удалось проверить подключение.",
"TEST_PROTOCOLS": "Проверить HTTPS и HTTP",
"HTTP_PERMISSION": "Если HTTPS недоступен, эта проверка может отправить логин и пароль по незашифрованному HTTP."
}
},
"STALKER_PORTAL": {
"TITLE": "Название",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "Parola",
"ADD": "Ekle",
"TEST_CREDENTIALS": "Kimlik bilgilerini test et",
"URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com veya https://example.com:4324)"
"URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com veya https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "Bağlantıyı test etmek için kullanıcı adı ve parola girin.",
"TESTING": "Bağlantı test ediliyor…",
"HTTP_CONNECTED": "HTTP üzerinden bağlanıldı. HTTPS kullanılamıyor. Bağlantı şifrelenmemiştir.",
"ACTIVE": "Bağlanıldı. Portal etkin.",
"INACTIVE": "Hesap etkin değil veya giriş bilgileri yanlış.",
"EXPIRED": "Abonelik süresi dolmuş.",
"UNAVAILABLE": "Portal geçerli hesap bilgileri döndürmedi.",
"HTTP_ERROR": "Sunucu HTTP {{status}} döndürdü.",
"TLS_ERROR": "Güvenli bağlantı kurulamadı. Sunucunun HTTPS yapılandırmasını ve sertifikasını kontrol edin.",
"CONNECTION_ERROR": "Sunucuya ulaşılamadı. Adresi ve ağ bağlantınızı kontrol edin.",
"UNKNOWN_ERROR": "Bağlantı test edilemedi.",
"TEST_PROTOCOLS": "HTTPS ve HTTP test et",
"HTTP_PERMISSION": "HTTPS kullanılamıyorsa bu test kullanıcı adınızı ve parolanızı şifrelenmemiş HTTP üzerinden gönderebilir."
}
},
"STALKER_PORTAL": {
"TITLE": "Başlık",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "密码",
"ADD": "添加",
"TEST_CREDENTIALS": "测试凭据",
"URL_VALIDATION_ERROR": "应该是带有协议的有效 url(例如 http://example.com 或 https://example.com:4324)"
"URL_VALIDATION_ERROR": "应该是带有协议的有效 url(例如 http://example.com 或 https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "请输入用户名和密码以测试连接。",
"TESTING": "正在测试连接…",
"HTTP_CONNECTED": "已通过 HTTP 连接。HTTPS 不可用。连接未加密。",
"ACTIVE": "已连接。门户处于活动状态。",
"INACTIVE": "账户未激活或登录信息不正确。",
"EXPIRED": "订阅已过期。",
"UNAVAILABLE": "门户未返回有效的账户信息。",
"HTTP_ERROR": "服务器返回 HTTP {{status}}。",
"TLS_ERROR": "无法建立安全连接。请检查服务器的 HTTPS 配置和证书。",
"CONNECTION_ERROR": "无法连接到服务器。请检查地址和网络连接。",
"UNKNOWN_ERROR": "无法测试连接。",
"TEST_PROTOCOLS": "测试 HTTPS 和 HTTP",
"HTTP_PERMISSION": "如果 HTTPS 不可用,此测试可能通过未加密的 HTTP 发送您的用户名和密码。"
}
},
"STALKER_PORTAL": {
"TITLE": "标题",
+16 -1
View File
@@ -181,7 +181,22 @@
"PASSWORD": "密碼",
"ADD": "新增",
"TEST_CREDENTIALS": "測試憑證",
"URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com 或 https://example.com:4324)"
"URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com 或 https://example.com:4324)",
"CONNECTION_TEST": {
"CREDENTIALS_REQUIRED": "請輸入使用者名稱和密碼以測試連線。",
"TESTING": "正在測試連線…",
"HTTP_CONNECTED": "已透過 HTTP 連線。HTTPS 無法使用。連線未加密。",
"ACTIVE": "已連線。入口網站處於啟用狀態。",
"INACTIVE": "帳戶未啟用或登入資訊不正確。",
"EXPIRED": "訂閱已到期。",
"UNAVAILABLE": "入口網站未傳回有效的帳戶資訊。",
"HTTP_ERROR": "伺服器傳回 HTTP {{status}}。",
"TLS_ERROR": "無法建立安全連線。請檢查伺服器的 HTTPS 設定和憑證。",
"CONNECTION_ERROR": "無法連線至伺服器。請檢查位址和網路連線。",
"UNKNOWN_ERROR": "無法測試連線。",
"TEST_PROTOCOLS": "測試 HTTPS 和 HTTP",
"HTTP_PERMISSION": "如果 HTTPS 無法使用,此測試可能透過未加密的 HTTP 傳送您的使用者名稱和密碼。"
}
},
"STALKER_PORTAL": {
"TITLE": "標題",
@@ -48,7 +48,7 @@ Rules:
2. URL username/password credentials are rejected.
3. Leading and trailing whitespace is ignored.
4. Trailing slashes are removed.
5. Full API or playlist URLs ending in `/player_api.php` or `/get.php` are
5. Full API or playlist URLs ending in `/player_api.php`, `/panel_api.php`, or `/get.php` are
reduced to the portal base URL.
6. Provider subpaths are preserved. For example,
`https://example.test/panel/player_api.php?...` becomes
@@ -58,6 +58,55 @@ The Xtream import form may extract `username` and `password` from full
`get.php` or `player_api.php` URLs, but stored playlist metadata should keep
the normalized `serverUrl` plus trimmed credentials.
### Explicit protocol discovery
Add and Edit source share `XtreamConnectionTestService` and form-owned
`createXtreamConnectionTestState` (`@iptvnator/services`). The explicit
**Test HTTPS and HTTP** button has a visible, accessible pre-request notice
that credentials may be sent over unencrypted HTTP. Clicking that action supplies
`allowHttpFallback`; the service defaults it to false, so an ordinary programmatic
test cannot authorize plaintext credentials. No modal or persistent opt-in is
needed. The test first probes the entered base, including the existing account-action variants.
Only an initial `ECONNREFUSED` or TLS wrong-version failure permits one HTTP
candidate on the same hostname/path. Default HTTPS port 443 becomes HTTP 80;
nonstandard explicit ports are preserved, never scanned. DNS, timeout, reset,
certificate, HTTP authorization, and redirected-destination failures cannot
trigger a downgrade. Aggregate and nested cause errors require positive
evidence from every address; mixed failures, cycles and truncated error trees
fail closed. TLS verification diagnostics include incomplete certificate chains.
A response from an earlier account-action variant also
prevents downgrade. HTTP failures (including panels returning 500 for unsupported actions)
still try the remaining account actions on the same candidate.
An active account on HTTP replaces only the form's `serverUrl`, with localized
copy explaining that HTTP is unencrypted. Add/Save persists through the existing
metadata path; Test never writes storage. Edits, reset, destruction and newer
tests invalidate pending results and prevent a stale fallback request. Add/Save
is disabled while that form's test is running. Empty or whitespace-only
credentials produce a localized validation message without a network request. Passive status checks, startup,
refresh and playback never perform protocol discovery.
Every completed current test refreshes `PortalStatusService` for the exact
connection: account responses publish status and expiration; terminal failures
publish unavailable with no expiration, without another network request.
Older passive checks cannot overwrite this explicit evidence; stale form
results do not publish it.
Both transports return an optional, credential-free `connectionFailure` envelope
only for `connectionTest` requests. Electron returns it rather than throwing
through IPC (which loses custom error fields); the PWA proxy strips the control
parameter before contacting the provider and preserves validated redirect-chain
evidence. PWA URL/DNS-policy refusals and Electron URL/redirect-policy errors are local
connection failures, never reported as provider HTTP statuses or used to
authorize HTTP. Older backends without the envelope cannot authorize HTTP discovery.
Provider JSON remains nested in `payload` and cannot provide this evidence.
The saved base drives catalog refresh, provider EPG, live/VOD/series/catch-up URL
construction and fresh Favorites/Recent resolution. The routed Xtream session
observes metadata connection changes and bootstraps the new connection. Separate
XMLTV source URLs, provider-supplied absolute URLs and already-issued download
or playback sessions retain their own URLs; they are not rewritten. No database
schema change or migration is required.
## Account Status
Account status handling uses `resolveXtreamPortalStatus`.
@@ -69,6 +69,14 @@
</mat-dialog-content>
<mat-dialog-actions class="apd-actions">
@if (playlistType() === 'xtream') {
<p class="w-full" id="xtream-http-test-notice">
{{
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.HTTP_PERMISSION'
| translate
}}
</p>
}
<div class="apd-actions__reset">
<button
mat-button
@@ -131,19 +139,24 @@
!xtreamImport()?.form?.valid ||
xtreamImport()?.isTestingConnection
"
(click)="xtreamImport()?.testConnection()"
(click)="xtreamImport()?.testConnection(true)"
aria-describedby="xtream-http-test-notice"
>
{{
xtreamImport()?.isTestingConnection
? 'Testing...'
: 'Test Connection'
(xtreamImport()?.isTestingConnection
? 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.TESTING'
: 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.TEST_PROTOCOLS'
) | translate
}}
</button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="!xtreamImport()?.form?.valid"
[disabled]="
!xtreamImport()?.form?.valid ||
xtreamImport()?.isTestingConnection
"
(click)="xtreamImport()?.addPlaylist()"
>
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
@@ -21,6 +21,14 @@
}}</mat-hint>
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
</mat-form-field>
@if (connectionTest.messageKey()) {
<p role="status" aria-live="polite">
{{
connectionTest.messageKey()
| translate: connectionTest.messageParams()
}}
</p>
}
<mat-form-field class="w-full">
<mat-label for="username">{{
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
@@ -33,13 +41,4 @@
}}</mat-label>
<input matInput type="text" id="password" formControlName="password" />
</mat-form-field>
@if (connectionStatus) {
<div class="connection-status">
<mat-icon [class]="getStatusClass()">
{{ getStatusIcon() }}
</mat-icon>
<span [class]="getStatusClass()">{{ getStatusMessage() }}</span>
</div>
}
</form>
@@ -1,26 +1,33 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { PortalStatusService } from '@iptvnator/services';
import { XtreamConnectionTestService } from '@iptvnator/services';
import { XtreamCodeImportComponent } from './xtream-code-import.component';
describe('XtreamCodeImportComponent', () => {
let component: XtreamCodeImportComponent;
let store: { dispatch: jest.Mock };
let portalStatusService: { checkPortalStatus: jest.Mock };
let connectionTestService: { test: jest.Mock };
beforeEach(() => {
store = {
dispatch: jest.fn(),
};
portalStatusService = {
checkPortalStatus: jest.fn().mockResolvedValue('active'),
connectionTestService = {
test: jest.fn().mockResolvedValue({
status: 'active',
serverUrl: 'http://example.com',
usedHttpFallback: true,
}),
};
TestBed.configureTestingModule({
providers: [
{ provide: Store, useValue: store },
{ provide: PortalStatusService, useValue: portalStatusService },
{
provide: XtreamConnectionTestService,
useValue: connectionTestService,
},
],
});
@@ -50,11 +57,11 @@ describe('XtreamCodeImportComponent', () => {
expect(component.form.valid).toBe(false);
await component.testConnection();
await component.testConnection(true);
component.addPlaylist();
expect(component.isTestingConnection).toBe(false);
expect(portalStatusService.checkPortalStatus).not.toHaveBeenCalled();
expect(connectionTestService.test).not.toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalled();
});
@@ -89,4 +96,105 @@ describe('XtreamCodeImportComponent', () => {
})
);
});
it('puts the tested HTTP address into the form and the added playlist', async () => {
component.form.patchValue({
title: 'Portal',
serverUrl: 'https://example.com',
username: 'user',
password: 'pass',
});
await component.testConnection(true);
expect(component.form.value.serverUrl).toBe('http://example.com');
expect(component.connectionTest.messageKey()).toContain(
'HTTP_CONNECTED'
);
component.addPlaylist();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.addPlaylist({
playlist: expect.objectContaining({
serverUrl: 'http://example.com',
}),
})
);
});
it.each(['serverUrl', 'username', 'password', 'title'])(
'discards a late result after editing %s',
async (field) => {
component.form.patchValue({
title: 'Portal',
serverUrl: 'https://example.com',
username: 'user',
password: 'pass',
});
let finish!: (result: unknown) => void;
connectionTestService.test.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
})
);
const pending = component.testConnection(true);
component.addPlaylist();
expect(store.dispatch).not.toHaveBeenCalled();
component.form.get(field)?.setValue('new-value');
finish({
status: 'active',
serverUrl: 'http://example.com',
usedHttpFallback: true,
});
await pending;
expect(component.connectionTest.result()).toBeNull();
expect(component.form.get(field)?.value).toBe('new-value');
expect(component.isTestingConnection).toBe(false);
}
);
it('discards a pending result when the form is cleared', async () => {
component.form.patchValue({
title: 'Portal',
serverUrl: 'https://example.com',
username: 'user',
password: 'pass',
});
let finish!: (result: unknown) => void;
connectionTestService.test.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
})
);
const pending = component.testConnection(true);
component.clearForm();
finish({
status: 'active',
serverUrl: 'http://example.com',
usedHttpFallback: true,
});
await pending;
expect(component.form.value.serverUrl).toBe('');
expect(component.connectionTest.result()).toBeNull();
});
it('does not update a destroyed form', async () => {
component.form.patchValue({
title: 'Portal',
serverUrl: 'https://example.com',
username: 'user',
password: 'pass',
});
let finish!: (result: unknown) => void;
connectionTestService.test.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
})
);
const pending = component.testConnection(true);
TestBed.resetTestingModule();
finish({
status: 'active',
serverUrl: 'http://example.com',
usedHttpFallback: true,
});
await pending;
expect(component.form.value.serverUrl).toBe('https://example.com');
expect(component.connectionTest.result()).toBeNull();
});
});
@@ -9,12 +9,11 @@ import {
Validators,
} from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { PortalStatus, PortalStatusService } from '@iptvnator/services';
import { createXtreamConnectionTestState } from '@iptvnator/services';
import {
createRandomId,
extractXtreamCredentialsFromUrl,
@@ -42,7 +41,6 @@ function xtreamServerUrlValidator(
imports: [
FormsModule,
MatFormFieldModule,
MatIcon,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
@@ -60,29 +58,6 @@ function xtreamServerUrlValidator(
form {
width: 100%;
}
.status-active {
color: #4caf50;
}
.status-inactive {
color: #f44336;
}
.status-expired {
color: #ff9800;
}
.status-unavailable {
color: #9e9e9e;
}
.connection-status {
margin: 10px 0;
display: flex;
align-items: center;
gap: 8px;
}
`,
],
})
@@ -104,47 +79,14 @@ export class XtreamCodeImportComponent {
});
readonly store = inject(Store);
readonly portalStatusService = inject(PortalStatusService);
readonly connectionTest = createXtreamConnectionTestState(this.form);
connectionStatus: PortalStatus | null = null;
isTestingConnection = false;
async testConnection(): Promise<void> {
if (!this.form.valid) return;
const connection = this.getNormalizedConnection();
if (!connection) {
this.connectionStatus = 'unavailable';
return;
}
this.isTestingConnection = true;
try {
// User-initiated connection test — bypass the shared cache so the
// result reflects the portal's current state, not whatever was
// cached up to 30 s ago by another component.
this.connectionStatus =
await this.portalStatusService.checkPortalStatus(
connection.serverUrl,
connection.username,
connection.password,
{ skipCache: true }
);
} finally {
this.isTestingConnection = false;
}
get isTestingConnection(): boolean {
return this.connectionTest.testing();
}
getStatusMessage(): string {
return this.portalStatusService.getStatusMessage(this.connectionStatus);
}
getStatusClass(): string {
return this.portalStatusService.getStatusClass(this.connectionStatus);
}
getStatusIcon(): string {
return this.portalStatusService.getStatusIcon(this.connectionStatus);
testConnection(allowHttpFallback = false): Promise<void> {
return this.connectionTest.test(allowHttpFallback);
}
clearForm(): void {
@@ -156,11 +98,10 @@ export class XtreamCodeImportComponent {
serverUrl: '',
importDate: new Date().toISOString(),
});
this.connectionStatus = null;
}
addPlaylist() {
if (!this.form.valid) return;
if (!this.form.valid || this.isTestingConnection) return;
const connection = this.getNormalizedConnection();
if (!connection) {
@@ -110,6 +110,36 @@
<input matInput formControlName="password" />
</mat-form-field>
}
@if (playlist.serverUrl && !playlist.portalUrl) {
<p id="xtream-edit-http-test-notice">
{{
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.HTTP_PERMISSION'
| translate
}}
</p>
<button
mat-stroked-button
type="button"
[disabled]="
connectionTest.testing() || playlistDetails.invalid
"
(click)="connectionTest.test(true)"
aria-describedby="xtream-edit-http-test-notice"
>
{{
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.TEST_PROTOCOLS'
| translate
}}
</button>
@if (connectionTest.messageKey()) {
<p role="status" aria-live="polite">
{{
connectionTest.messageKey()
| translate: connectionTest.messageParams()
}}
</p>
}
}
@if (playlist.url) {
<mat-form-field class="w-full">
<mat-label>{{
@@ -364,6 +394,7 @@
[disabled]="
!playlistDetails.valid ||
playlistDetails.pristine ||
connectionTest.testing() ||
isSaving() ||
isHydratingStalkerPlaylist() ||
stalkerPlaylistHydrationFailed()
@@ -26,6 +26,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { firstValueFrom } from 'rxjs';
import {
createXtreamConnectionTestState,
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
@@ -296,6 +297,7 @@ export class PlaylistInfoComponent {
/** Form group with playlist details */
playlistDetails!: UntypedFormGroup;
readonly connectionTest: ReturnType<typeof createXtreamConnectionTestState>;
constructor() {
this.dialogRef?.beforeClosed().subscribe(() => {
@@ -303,6 +305,9 @@ export class PlaylistInfoComponent {
});
this.playlist = this.playlistData;
this.createForm();
this.connectionTest = createXtreamConnectionTestState(
this.playlistDetails
);
if (this.playlist.portalUrl) {
this.isHydratingStalkerPlaylist.set(true);
this.stalkerPlaylistHydration =
@@ -375,7 +380,7 @@ export class PlaylistInfoComponent {
}
async saveChanges(playlist: PlaylistMeta): Promise<void> {
if (this.isSaving()) {
if (this.isSaving() || this.connectionTest.testing()) {
return;
}
@@ -607,13 +612,8 @@ export class PlaylistInfoComponent {
throw new Error('Failed to update playlist in database');
}
// TODO: circular dependency
/* this.xtreamStore.updatePlaylist({
name: playlist.title,
username: playlist.username,
password: playlist.password,
serverUrl: playlist.serverUrl,
}); */
// The metadata action below updates PlaylistContextFacade; the routed
// Xtream session observes connection changes and bootstraps fresh state.
}
async refreshPlaylistEpgSource(url: string): Promise<void> {
+3
View File
@@ -23,3 +23,6 @@ export * from './lib/stream-probe.service';
export * from './lib/vod-source-pin.service';
export * from './lib/epg-source-settings.service';
export * from './lib/xtream-connection-test.service';
export * from './lib/xtream-connection-test-state';
+50 -9
View File
@@ -119,8 +119,9 @@ export class PortalStatusService {
connection.password
);
const cachedAtStart = this.cache.get(cacheKey);
if (!options?.skipCache) {
const cached = this.cache.get(cacheKey);
const cached = cachedAtStart;
if (
cached &&
Date.now() - cached.timestamp < PORTAL_STATUS_CACHE_TTL_MS
@@ -148,19 +149,29 @@ export class PortalStatusService {
connection.password
)
.then((details) => {
this.cache.set(cacheKey, {
details,
timestamp: Date.now(),
});
if (this.inFlight.get(cacheKey) === request) {
this.cache.set(cacheKey, {
details,
timestamp: Date.now(),
});
} else {
// Existing callers also receive the newer explicit evidence.
const newer = this.cache.get(cacheKey);
return newer &&
newer !== cachedAtStart &&
Date.now() - newer.timestamp <
PORTAL_STATUS_CACHE_TTL_MS
? newer.details
: details;
}
return details;
})
.finally(() => {
this.inFlight.delete(cacheKey);
if (this.inFlight.get(cacheKey) === request)
this.inFlight.delete(cacheKey);
});
if (!options?.skipCache) {
this.inFlight.set(cacheKey, request);
}
this.inFlight.set(cacheKey, request);
return request;
}
@@ -199,9 +210,39 @@ export class PortalStatusService {
return cached.details.status;
}
/** Publish explicit probe evidence without another network request. */
rememberXtreamResponse(
serverUrl: string,
username: string,
password: string,
response: XtreamPortalStatusResponseLike | undefined
): void {
const connection = this.normalizeConnection(
serverUrl,
username,
password
);
if (!connection) return;
const key = this.buildCacheKey(
connection.serverUrl,
connection.username,
connection.password
);
// A passive check started before this evidence cannot overwrite it.
this.inFlight.delete(key);
this.cache.set(key, {
details: {
status: resolveXtreamPortalStatus(response),
expiresAtSeconds: resolveXtreamPortalExpiration(response),
},
timestamp: Date.now(),
});
}
/** Clear the entire cache. Useful for log-out or debug flows. */
clearStatusCache(): void {
this.cache.clear();
this.inFlight.clear();
}
private buildCacheKey(
@@ -0,0 +1,94 @@
import {
EnvironmentInjector,
Injector,
createEnvironmentInjector,
runInInjectionContext,
} from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { createXtreamConnectionTestState } from './xtream-connection-test-state';
import { XtreamConnectionTestService } from './xtream-connection-test.service';
describe('Xtream form connection test lifecycle', () => {
it.each([
['', 'pass'],
['user', ''],
[' ', 'pass'],
['user', ' '],
])(
'explains empty credentials without requesting the portal: %j / %j',
async (username, password) => {
const test = jest
.fn()
.mockResolvedValue({
status: 'active',
serverUrl: 'https://panel.test',
usedHttpFallback: false,
});
const injector = createEnvironmentInjector(
[{ provide: XtreamConnectionTestService, useValue: { test } }],
Injector.NULL as unknown as EnvironmentInjector
);
const form = new FormGroup({
serverUrl: new FormControl('https://panel.test'),
username: new FormControl(username),
password: new FormControl(password),
});
const state = runInInjectionContext(injector, () =>
createXtreamConnectionTestState(form)
);
try {
await state.test(true);
expect(state.messageKey()).toBe(
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.CREDENTIALS_REQUIRED'
);
expect(state.testing()).toBe(false);
expect(test).not.toHaveBeenCalled();
form.patchValue({ username: 'user', password: 'pass' });
expect(state.messageKey()).toBe('');
await state.test(true);
expect(state.messageKey()).toBe(
'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.ACTIVE'
);
expect(test).toHaveBeenCalledTimes(1);
} finally {
injector.destroy();
}
}
);
it('releases the form subscription and ignores in-flight results on destroy', async () => {
let complete!: (result: unknown) => void;
const test = jest.fn(
() =>
new Promise((resolve) => {
complete = resolve;
})
);
const injector = createEnvironmentInjector(
[{ provide: XtreamConnectionTestService, useValue: { test } }],
Injector.NULL as unknown as EnvironmentInjector
);
const form = new FormGroup({
serverUrl: new FormControl('https://panel.test'),
username: new FormControl('user'),
password: new FormControl('pass'),
});
const subscribe = jest.spyOn(form.valueChanges, 'subscribe');
const state = runInInjectionContext(injector, () =>
createXtreamConnectionTestState(form)
);
const pending = state.test(true);
expect(subscribe.mock.results[0].value.closed).toBe(false);
injector.destroy();
expect(subscribe.mock.results[0].value.closed).toBe(true);
complete({
status: 'active',
serverUrl: 'http://panel.test',
usedHttpFallback: true,
});
await pending;
expect(form.controls.serverUrl.value).toBe('https://panel.test');
expect(state.result()).toBeNull();
expect(test.mock.calls).toHaveLength(1);
});
});
@@ -0,0 +1,86 @@
import { computed, DestroyRef, inject, Injector, signal } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import {
XtreamConnectionTestResult,
XtreamConnectionTestService,
XtreamTestConnection,
} from './xtream-connection-test.service';
/** Form-owned state shared by add and edit; closing or editing invalidates work. */
export function createXtreamConnectionTestState(form: AbstractControl) {
const injector = inject(Injector);
const destroyRef = inject(DestroyRef);
const testing = signal(false);
const result = signal<XtreamConnectionTestResult | null>(null);
const credentialsMissing = signal(false);
let generation = 0;
const changes = form.valueChanges.subscribe(() => {
generation++;
result.set(null);
credentialsMissing.set(false);
testing.set(false);
});
destroyRef.onDestroy(() => {
generation++;
changes.unsubscribe();
});
const messageKey = computed(() => {
if (testing()) return 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.TESTING';
if (credentialsMissing())
return 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.CREDENTIALS_REQUIRED';
const value = result();
if (!value) return '';
const key = value.usedHttpFallback
? 'HTTP_CONNECTED'
: value.failure
? value.failure.kind.toUpperCase() + '_ERROR'
: value.status.toUpperCase();
return 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.' + key;
});
return {
testing,
result,
messageKey,
messageParams: computed(() => ({ status: result()?.failure?.status })),
async test(allowHttpFallback = false): Promise<void> {
if (testing()) return;
const connection = form.getRawValue() as XtreamTestConnection;
if (!connection.username?.trim() || !connection.password?.trim()) {
result.set(null);
credentialsMissing.set(true);
return;
}
if (form.invalid) return;
credentialsMissing.set(false);
const owned = ++generation;
const isCurrent = () =>
owned === generation && !destroyRef.destroyed;
testing.set(true);
result.set(null);
try {
const response = await injector
.get(XtreamConnectionTestService)
.test(connection, isCurrent, allowHttpFallback);
if (!isCurrent()) return;
if (response.usedHttpFallback) {
form.get('serverUrl')?.setValue(response.serverUrl, {
emitEvent: false,
});
form.get('serverUrl')?.markAsDirty();
}
result.set(response);
} catch {
if (isCurrent())
result.set({
status: 'unavailable',
serverUrl: connection.serverUrl,
usedHttpFallback: false,
});
} finally {
if (isCurrent()) testing.set(false);
}
},
};
}
@@ -0,0 +1,400 @@
import {
EnvironmentInjector,
Injector,
createEnvironmentInjector,
runInInjectionContext,
} from '@angular/core';
import { DataService } from './data.service';
import { PortalStatusService } from './portal-status.service';
import { XtreamConnectionTestService } from './xtream-connection-test.service';
describe('explicit Xtream connection test', () => {
const connection = {
serverUrl: 'https://panel.test/base/get.php?type=m3u',
username: ' user ',
password: ' pass ',
};
const active = { payload: { user_info: { auth: 1, status: 'Active' } } };
const refused = {
connectionFailure: { kind: 'connection', canTryHttp: true },
};
let send: jest.Mock;
let probe: jest.Mock;
let service: XtreamConnectionTestService;
let portalStatus: PortalStatusService;
beforeEach(() => {
probe = jest.fn();
send = jest.fn((type, payload) =>
type === 'XTREAM_REQUEST' ? probe(payload) : Promise.resolve()
);
const injector = createEnvironmentInjector(
[
PortalStatusService,
{ provide: DataService, useValue: { sendIpcEvent: send } },
],
Injector.NULL as unknown as EnvironmentInjector
);
portalStatus = injector.get(PortalStatusService);
service = runInInjectionContext(
injector,
() => new XtreamConnectionTestService()
);
});
it.each(['https://panel.test/base', 'http://panel.test/base'])(
'replaces stale status and expiration for the successful address %s',
async (serverUrl) => {
probe.mockResolvedValueOnce({
payload: { user_info: { auth: 0 } },
});
await portalStatus.checkPortalStatus(serverUrl, 'user', 'pass');
if (serverUrl.startsWith('http:'))
probe.mockResolvedValueOnce(refused);
const expires = Math.floor(Date.now() / 1000) + 3600;
probe.mockResolvedValueOnce({
payload: {
user_info: {
auth: 1,
status: 'Active',
exp_date: String(expires),
},
},
});
await service.test(connection, () => true, true);
const calls = probe.mock.calls.length;
expect(
await portalStatus.checkPortalStatusDetails(
serverUrl,
' user ',
' pass '
)
).toEqual({ status: 'active', expiresAtSeconds: expires });
expect(probe).toHaveBeenCalledTimes(calls);
}
);
it('does not publish a response after the form has changed', async () => {
let current = true;
probe.mockImplementation(() => {
current = false;
return active;
});
await service.test(connection, () => current, true);
expect(
portalStatus.getCachedStatus(
'https://panel.test/base',
'user',
'pass'
)
).toBeNull();
});
it('keeps explicit evidence when an older passive check completes later', async () => {
let finish!: (value: unknown) => void;
probe.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = resolve;
})
);
const oldCheck = portalStatus.checkPortalStatus(
'https://panel.test/base',
'user',
'pass'
);
probe.mockResolvedValueOnce(active);
await service.test(connection);
finish({ payload: { user_info: { auth: 0 } } });
expect(await oldCheck).toBe('active');
expect(
portalStatus.getCachedStatus(
'https://panel.test/base',
'user',
'pass'
)
).toBe('active');
});
it('does not substitute an expired entry while a replacement check is pending', async () => {
const now = jest.spyOn(Date, 'now').mockReturnValue(0);
try {
probe.mockResolvedValueOnce({
payload: { user_info: { auth: 0 } },
});
await portalStatus.checkPortalStatus(
'https://panel.test/base',
'user',
'pass'
);
now.mockReturnValue(31_000);
let finishOld!: (value: unknown) => void;
let finishNew!: (value: unknown) => void;
let newStarted!: () => void;
const started = new Promise<void>((resolve) => {
newStarted = resolve;
});
probe.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishOld = resolve;
})
);
const oldCheck = portalStatus.checkPortalStatus(
'https://panel.test/base',
'user',
'pass'
);
probe.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishNew = resolve;
newStarted();
})
);
const replacement = portalStatus.checkPortalStatus(
'https://panel.test/base',
'user',
'pass',
{ skipCache: true }
);
await started;
finishOld(active);
expect(await oldCheck).toBe('active');
finishNew(active);
expect(await replacement).toBe('active');
} finally {
now.mockRestore();
}
});
it.each(['transport', 'http', 'malformed', 'exception'])(
'replaces cached active evidence after a final %s failure',
async (kind) => {
probe.mockResolvedValueOnce(active);
await portalStatus.checkPortalStatus(
'https://panel.test/base',
'user',
'pass'
);
if (kind === 'exception')
probe.mockRejectedValue(new Error('offline'));
else
probe.mockResolvedValue(
kind === 'transport'
? {
connectionFailure: {
kind: 'tls',
canTryHttp: false,
},
}
: kind === 'http'
? {
connectionFailure: {
kind: 'http',
status: 500,
canTryHttp: false,
},
}
: { payload: '<html>not an account</html>' }
);
expect(
(await service.test(connection, () => true, true)).status
).toBe('unavailable');
const calls = probe.mock.calls.length;
expect(
await portalStatus.checkPortalStatusDetails(
'https://panel.test/base',
'user',
'pass'
)
).toEqual({ status: 'unavailable', expiresAtSeconds: null });
expect(probe).toHaveBeenCalledTimes(calls);
}
);
it('preserves action compatibility for legacy IPC exceptions without authorizing HTTP', async () => {
probe
.mockRejectedValueOnce(new Error('legacy action failed'))
.mockResolvedValueOnce(active);
expect((await service.test(connection, () => true, true)).status).toBe(
'active'
);
expect(probe.mock.calls[1][0].params).not.toHaveProperty('action');
probe.mockReset();
probe
.mockRejectedValueOnce(new Error('legacy action failed'))
.mockResolvedValueOnce(refused);
expect(
(await service.test(connection, () => true, true)).usedHttpFallback
).toBe(false);
expect(probe.mock.calls.map(([p]) => p.url)).toEqual([
'https://panel.test/base',
'https://panel.test/base',
]);
});
it('does not downgrade after HTTP 500 followed by a transport failure', async () => {
probe
.mockResolvedValueOnce({
connectionFailure: {
kind: 'http',
status: 500,
canTryHttp: false,
},
})
.mockResolvedValueOnce(refused);
expect(
(await service.test(connection, () => true, true)).usedHttpFallback
).toBe(false);
expect(probe.mock.calls.map(([p]) => p.url)).toEqual([
'https://panel.test/base',
'https://panel.test/base',
]);
});
it('does not publish a failed test after the form has changed', async () => {
probe.mockResolvedValueOnce(active);
await portalStatus.checkPortalStatus(
'https://panel.test/base',
'user',
'pass'
);
let current = true;
probe.mockImplementation(() => {
current = false;
return refused;
});
await service.test(connection, () => current, true);
expect(
portalStatus.getCachedStatus(
'https://panel.test/base',
'user',
'pass'
)
).toBe('active');
});
it('prefers working HTTPS and makes no HTTP request', async () => {
probe.mockResolvedValue(active);
expect(await service.test(connection, () => true, true)).toMatchObject({
status: 'active',
usedHttpFallback: false,
serverUrl: 'https://panel.test/base',
});
expect(probe).toHaveBeenCalledTimes(1);
});
it('resets each candidate and returns an authenticated HTTP base for saving', async () => {
probe.mockResolvedValueOnce(refused).mockResolvedValueOnce(active);
expect(await service.test(connection, () => true, true)).toMatchObject({
status: 'active',
serverUrl: 'http://panel.test/base',
usedHttpFallback: true,
});
expect(probe.mock.calls.map(([p]) => p.url)).toEqual([
'https://panel.test/base',
'http://panel.test/base',
]);
expect(probe.mock.calls[1][0]).toMatchObject({
connectionTest: true,
params: { username: 'user', password: 'pass' },
});
expect(
send.mock.calls
.filter(([t]) => t === 'CONNECTIVITY_GUARD_RESET')
.map(([, p]) => p.url)
).toEqual(['https://panel.test/base', 'http://panel.test/base']);
});
it.each([
{ connectionFailure: { kind: 'http', status: 403, canTryHttp: false } },
{ connectionFailure: { kind: 'tls', canTryHttp: false } },
{ payload: { user_info: { auth: 0 } } },
{ payload: { user_info: { auth: 1, status: 'Expired' } } },
{ payload: '<html>challenge</html>' },
])(
'does not downgrade a responsive or uncertified portal',
async (response) => {
probe.mockResolvedValue(response);
const result = await service.test(connection, () => true, true);
expect(result.usedHttpFallback).toBe(false);
expect(
probe.mock.calls.every(([p]) => p.url.startsWith('https:'))
).toBe(true);
}
);
it('still tries account action variants when the panel omits account info', async () => {
probe
.mockResolvedValueOnce({ payload: [] })
.mockResolvedValueOnce(active);
expect((await service.test(connection, () => true, true)).status).toBe(
'active'
);
expect(probe.mock.calls[1][0].params).not.toHaveProperty('action');
});
it('does not replace the address with an inactive fallback', async () => {
probe
.mockResolvedValueOnce(refused)
.mockResolvedValueOnce({ payload: { user_info: { auth: 0 } } });
expect(await service.test(connection, () => true, true)).toMatchObject({
status: 'inactive',
usedHttpFallback: false,
serverUrl: 'https://panel.test/base',
});
});
it('does not probe HTTP after the form is edited', async () => {
let current = true;
probe.mockImplementation(() => {
current = false;
return refused;
});
await service.test(connection, () => current, true);
expect(probe).toHaveBeenCalledTimes(1);
});
it('fails closed with an old backend or IPC exception', async () => {
probe.mockRejectedValue(new Error('ECONNREFUSED'));
expect(
(await service.test(connection, () => true, true)).usedHttpFallback
).toBe(false);
expect(probe).toHaveBeenCalledTimes(3);
});
it('does not downgrade after an earlier account action received a response', async () => {
probe
.mockResolvedValueOnce({ payload: [] })
.mockResolvedValueOnce(refused);
const result = await service.test(connection, () => true, true);
expect(result.usedHttpFallback).toBe(false);
expect(probe).toHaveBeenCalledTimes(2);
expect(
probe.mock.calls.every(([p]) => p.url.startsWith('https:'))
).toBe(true);
});
it.each([400, 401, 403, 404, 405, 429, 500, 503])(
'preserves account-action compatibility for an HTTP %i response',
async (status) => {
probe
.mockResolvedValueOnce({
connectionFailure: {
kind: 'http',
status,
canTryHttp: false,
},
})
.mockResolvedValueOnce(active);
expect(
(await service.test(connection, () => true, true)).status
).toBe('active');
expect(probe.mock.calls[1][0].params).not.toHaveProperty('action');
}
);
it('does not send credentials over HTTP without explicit permission', async () => {
probe.mockResolvedValueOnce(refused).mockResolvedValueOnce(active);
expect((await service.test(connection)).usedHttpFallback).toBe(false);
expect(probe).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,129 @@
import { Injectable, inject } from '@angular/core';
import {
normalizeXtreamServerUrl,
resolveXtreamPortalStatus,
XtreamConnectionFailure,
XtreamPortalStatusResponseLike,
XtreamPortalStatusType,
xtreamHttpAlternative,
} from '@iptvnator/shared/interfaces';
import { DataService } from './data.service';
import { resetHostConnectivityGuard } from './host-connectivity-reset';
import { PortalStatusService } from './portal-status.service';
export interface XtreamTestConnection {
serverUrl: string;
username: string;
password: string;
}
export interface XtreamConnectionTestResult {
status: XtreamPortalStatusType;
serverUrl: string;
usedHttpFallback: boolean;
failure?: XtreamConnectionFailure;
}
@Injectable({ providedIn: 'root' })
export class XtreamConnectionTestService {
private readonly data = inject(DataService);
private readonly portalStatus = inject(PortalStatusService);
/** Explicit form action only. Passive status checks never discover protocols. */
async test(
connection: XtreamTestConnection,
isCurrent: () => boolean = () => true,
allowHttpFallback = false
): Promise<XtreamConnectionTestResult> {
const normalized = {
serverUrl: normalizeXtreamServerUrl(connection.serverUrl),
username: connection.username.trim(),
password: connection.password.trim(),
};
const first = await this.probe(normalized, isCurrent);
const alternative = xtreamHttpAlternative(normalized.serverUrl);
if (
!allowHttpFallback ||
!alternative ||
!first.failure?.canTryHttp ||
!isCurrent()
) {
return first;
}
const second = await this.probe(
{ ...normalized, serverUrl: alternative },
isCurrent
);
// Never replace a user's address with a failed or unauthenticated one.
return second.status === 'active'
? { ...second, usedHttpFallback: true }
: { ...second, serverUrl: normalized.serverUrl };
}
private async probe(
connection: XtreamTestConnection,
isCurrent: () => boolean
): Promise<XtreamConnectionTestResult> {
const result: XtreamConnectionTestResult = {
status: 'unavailable',
serverUrl: connection.serverUrl,
usedHttpFallback: false,
};
await resetHostConnectivityGuard(this.data, connection.serverUrl);
let responseObserved = false;
let accountResponse: XtreamPortalStatusResponseLike | undefined;
try {
for (const action of ['get_account_info', null, 'get_profile']) {
if (!isCurrent()) return result;
try {
const response = await this.data.sendIpcEvent<{
payload?: XtreamPortalStatusResponseLike;
connectionFailure?: XtreamConnectionFailure;
}>('XTREAM_REQUEST', {
url: connection.serverUrl,
params: {
username: connection.username,
password: connection.password,
...(action ? { action } : {}),
},
connectionTest: true,
suppressErrorLog: true,
});
if (response?.connectionFailure) {
result.failure = {
...response.connectionFailure,
canTryHttp:
!responseObserved &&
response.connectionFailure.canTryHttp,
};
if (response.connectionFailure.kind === 'http') {
responseObserved = true;
continue;
}
return result;
}
responseObserved = true;
const status = resolveXtreamPortalStatus(response?.payload);
if (status !== 'unavailable') {
accountResponse = response?.payload;
return { ...result, status, failure: undefined };
}
} catch {
// Legacy IPC exceptions may hide an unsupported account action.
// Try only same-candidate variants; ambiguous evidence can never
// authorize HTTP, including after a later classified failure.
responseObserved = true;
}
}
return result;
} finally {
if (isCurrent())
this.portalStatus.rememberXtreamResponse(
connection.serverUrl,
connection.username,
connection.password,
accountResponse
);
}
}
}
+2
View File
@@ -96,3 +96,5 @@ export * from './lib/vod-details-adapters';
export * from './lib/vod-details-item.interface';
export * from './lib/catchup-download.interface';
export * from './lib/xtream-connection-test';
@@ -1,3 +1,4 @@
import type { XtreamConnectionFailure } from './xtream-connection-test';
import type {
CatchupDownloadMetadata,
DownloadRecoveryResult,
@@ -306,6 +307,7 @@ export interface ElectronBridgeStalkerRequestPayload {
}
export interface ElectronBridgeXtreamRequestPayload {
connectionTest?: boolean;
url: string;
params: Record<string, string>;
requestId?: string;
@@ -314,6 +316,7 @@ export interface ElectronBridgeXtreamRequestPayload {
}
export interface ElectronBridgeXtreamResponse {
connectionFailure?: XtreamConnectionFailure;
payload: unknown;
action: string;
}
@@ -0,0 +1,178 @@
import {
describeXtreamConnectionFailure,
xtreamHttpAlternative,
} from './xtream-connection-test';
describe('Xtream connection transport evidence', () => {
it.each([
[
'https://panel.test/get.php?username=x&password=y',
'http://panel.test',
],
[
'https://panel.test:443/base/player_api.php',
'http://panel.test/base',
],
['https://panel.test:8443/base/', 'http://panel.test:8443/base'],
['http://panel.test', null],
])('builds only the same-host alternative for %s', (input, expected) => {
expect(xtreamHttpAlternative(input)).toBe(expected);
});
it.each(['ECONNREFUSED', 'ERR_SSL_WRONG_VERSION_NUMBER'])(
'accepts initial %s but excludes redirect failures',
(code) => {
expect(
describeXtreamConnectionFailure({ code }, false).canTryHttp
).toBe(true);
expect(
describeXtreamConnectionFailure({ code }, true).canTryHttp
).toBe(false);
}
);
it.each([
'ssl3_get_record:wrong version number',
'OPENSSL_internal:WRONG_VERSION_NUMBER',
])('recognizes a plaintext HTTP listener: %s', (message) => {
expect(
describeXtreamConnectionFailure(
{
code: 'EPROTO',
message,
},
false
).canTryHttp
).toBe(true);
});
it.each([
'ECONNRESET',
'ETIMEDOUT',
'ENOTFOUND',
'ERR_CANCELED',
'CERT_HAS_EXPIRED',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'ERR_TLS_CERT_ALTNAME_INVALID',
'EPROTO',
])('never authorizes HTTP for %s', (code) => {
expect(
describeXtreamConnectionFailure({ code }, false).canTryHttp
).toBe(false);
});
it.each([
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'INVALID_CA',
'HOSTNAME_MISMATCH',
'PATH_LENGTH_EXCEEDED',
'INVALID_PURPOSE',
'UNABLE_TO_GET_CRL',
])(
'recognizes certificate verification failure %s without allowing HTTP',
(code) => {
expect(describeXtreamConnectionFailure({ code }, false)).toEqual({
kind: 'tls',
canTryHttp: false,
});
}
);
it.each([undefined, 'ECONNREFUSED'])(
'requires all address failures to be positive (aggregate code=%s)',
(code) => {
const refused = { code: 'ECONNREFUSED' };
const aggregate = (other: unknown) => ({
code,
errors: [refused, other],
});
expect(
describeXtreamConnectionFailure(aggregate(refused), false)
.canTryHttp
).toBe(true);
for (const other of [
{ code: 'ETIMEDOUT' },
{ code: 'ENOTFOUND' },
{},
null,
]) {
expect(
describeXtreamConnectionFailure(aggregate(other), false)
.canTryHttp
).toBe(false);
}
expect(
describeXtreamConnectionFailure(aggregate(refused), true)
.canTryHttp
).toBe(false);
}
);
it('traverses wrappers and shared aggregate causes without losing TLS or HTTP evidence', () => {
const errors = [{ code: 'ECONNREFUSED' }, { code: 'ECONNREFUSED' }];
const aggregate = { errors };
expect(
describeXtreamConnectionFailure({ errors, cause: aggregate }, false)
.canTryHttp
).toBe(true);
expect(
describeXtreamConnectionFailure(
{
code: 'ECONNREFUSED',
cause: { code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' },
},
false
)
).toEqual({ kind: 'tls', canTryHttp: false });
expect(
describeXtreamConnectionFailure(
{ cause: { response: { status: 403 }, cause: aggregate } },
false
)
).toEqual({ kind: 'http', status: 403, canTryHttp: false });
});
it('fails closed for empty aggregates, cycles and oversized cause trees', () => {
expect(
describeXtreamConnectionFailure(
{ code: 'ECONNREFUSED', errors: [] },
false
).canTryHttp
).toBe(false);
const cycle: { code: string; cause?: unknown } = {
code: 'ECONNREFUSED',
};
cycle.cause = cycle;
expect(describeXtreamConnectionFailure(cycle, false).canTryHttp).toBe(
false
);
let deep: unknown = { code: 'ECONNREFUSED' };
for (let index = 0; index < 100; index++) deep = { cause: deep };
expect(describeXtreamConnectionFailure(deep, false).canTryHttp).toBe(
false
);
expect(
describeXtreamConnectionFailure(
{
errors: Array.from({ length: 100 }, () => ({
code: 'ECONNREFUSED',
})),
},
false
).canTryHttp
).toBe(false);
});
it('keeps HTTP status without provider text or credentials', () => {
expect(
describeXtreamConnectionFailure(
{
response: { status: 403 },
message: 'secret',
code: 'ECONNREFUSED',
},
false
)
).toEqual({ kind: 'http', status: 403, canTryHttp: false });
});
});
@@ -0,0 +1,103 @@
import { normalizeXtreamServerUrl } from './xtream-portal.utils';
export interface XtreamConnectionFailure {
kind: 'http' | 'tls' | 'connection' | 'unknown';
status?: number;
canTryHttp: boolean;
}
interface TransportFailureLike {
code?: unknown;
status?: unknown;
message?: unknown;
response?: { status?: unknown };
errors?: unknown;
cause?: unknown;
}
const unknownFailure = (): XtreamConnectionFailure => ({
kind: 'unknown',
canTryHttp: false,
});
const TLS_VERIFY_CODES = new Set([
'EPROTO',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'INVALID_CA',
'PATH_LENGTH_EXCEEDED',
'HOSTNAME_MISMATCH',
'INVALID_PURPOSE',
]);
/** Small, credential-free evidence produced by the transport, never by a panel. */
export function describeXtreamConnectionFailure(
error: unknown,
initialResponded: boolean
): XtreamConnectionFailure {
const active = new WeakSet<object>();
const memo = new WeakMap<object, XtreamConnectionFailure>();
let remaining = 64;
const visit = (value: unknown): XtreamConnectionFailure => {
if (!value || typeof value !== 'object') return unknownFailure();
const cached = memo.get(value);
if (cached) return cached;
if (active.has(value) || --remaining < 0) return unknownFailure();
active.add(value);
const candidate = value as TransportFailureLike;
const own = describeSingleFailure(candidate);
const evidence: XtreamConnectionFailure[] = own ? [own] : [];
// Node may summarize mixed IPv4/IPv6 failures with the first error's
// code. Require positive evidence from every address and nested cause.
if (candidate.errors !== undefined) {
if (Array.isArray(candidate.errors) && candidate.errors.length) {
if (candidate.errors.length > 64)
evidence.push(unknownFailure());
evidence.push(...candidate.errors.slice(0, 64).map(visit));
} else evidence.push(unknownFailure());
}
if (candidate.cause !== undefined)
evidence.push(visit(candidate.cause));
const detail =
evidence.find((e) => e.kind === 'http') ??
evidence.find((e) => e.kind === 'tls') ??
evidence.find((e) => e.kind === 'connection') ??
unknownFailure();
const result = {
...detail,
canTryHttp:
evidence.length > 0 && evidence.every((e) => e.canTryHttp),
};
active.delete(value);
memo.set(value, result);
return result;
};
const result = visit(error);
return { ...result, canTryHttp: !initialResponded && result.canTryHttp };
}
function describeSingleFailure(
candidate: TransportFailureLike
): XtreamConnectionFailure | null {
const status = candidate.response?.status ?? candidate.status;
if (typeof status === 'number' && status >= 100 && status <= 599)
return { kind: 'http', status, canTryHttp: false };
const code = typeof candidate.code === 'string' ? candidate.code : '';
if (!code) return null;
const wrongVersion =
code === 'ERR_SSL_WRONG_VERSION_NUMBER' ||
(code === 'EPROTO' &&
typeof candidate.message === 'string' &&
/wrong[ _]version[ _]number/i.test(candidate.message));
const tls = /CERT|TLS|SSL|CRL/.test(code) || TLS_VERIFY_CODES.has(code);
return {
kind: tls ? 'tls' : 'connection',
canTryHttp: code === 'ECONNREFUSED' || wrongVersion,
};
}
export function xtreamHttpAlternative(serverUrl: string): string | null {
const url = new URL(normalizeXtreamServerUrl(serverUrl));
if (url.protocol !== 'https:') return null;
// URL normalizes an explicit :443 away. Nonstandard ports stay explicit.
url.protocol = 'http:';
return normalizeXtreamServerUrl(url.href);
}