Merge pull request #1065 from 4gray/codex/xtream-portal-compatibility

fix(xtream): improve portal compatibility
This commit is contained in:
4gray authored and GitHub committed 2026-06-14 14:40:39 +02:00
commit 8739e95b70
27 files changed
+1469 -199

No files matched your search

@@ -52,6 +52,40 @@ describe('XtreamEvents session cancellation', () => {
consoleErrorSpy.mockRestore();
});
it('normalizes full Xtream API URLs before appending player_api.php', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
expect(requestHandler).toBeDefined();
axiosMock.mockResolvedValue({
status: 200,
data: { ok: true },
headers: {},
});
await requestHandler?.(
{},
{
url: 'https://example.com/base/player_api.php?username=old&password=old',
params: {
action: 'get_account_info',
password: ' pass ',
username: ' user ',
},
suppressErrorLog: true,
}
);
const requestedUrl = new URL(axiosMock.mock.calls[0][0].url);
expect(`${requestedUrl.origin}${requestedUrl.pathname}`).toBe(
'https://example.com/base/player_api.php'
);
expect(requestedUrl.searchParams.get('action')).toBe(
'get_account_info'
);
expect(requestedUrl.searchParams.get('password')).toBe('pass');
expect(requestedUrl.searchParams.get('username')).toBe('user');
});
it('aborts requests that were registered with only a session id', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
@@ -8,6 +8,7 @@ import { ipcMain } from 'electron';
import {
PortalDebugEvent,
XTREAM_CANCEL_SESSION,
normalizeXtreamServerUrl,
} from '@iptvnator/shared/interfaces';
import { emitPortalDebugEvent } from './portal-debug.events';
import { assertRemoteUrlAllowed, UnsafeUrlError } from './url-safety';
@@ -24,11 +25,16 @@ function formatXtreamError(
requestUrl: string,
action?: string
) {
const parsedUrl = new URL(requestUrl);
let parsedUrl: URL | null = null;
try {
parsedUrl = new URL(requestUrl);
} catch {
parsedUrl = null;
}
const base = {
action,
host: parsedUrl.host,
pathname: parsedUrl.pathname,
host: parsedUrl?.host ?? 'unknown',
pathname: parsedUrl?.pathname ?? requestUrl,
};
if (axios.isAxiosError(error)) {
@@ -60,6 +66,19 @@ function formatXtreamError(
};
}
function buildXtreamApiUrl(url: string, params: Record<string, string>): URL {
const baseUrl = normalizeXtreamServerUrl(url);
const apiUrl = new URL(`${baseUrl}/player_api.php`);
Object.entries(params).forEach(([key, value]) => {
apiUrl.searchParams.append(
key,
key === 'username' || key === 'password' ? value.trim() : value
);
});
return apiUrl;
}
/**
* Handle Xtream Codes API requests
*/
@@ -77,15 +96,14 @@ ipcMain.handle(
) => {
const startedAt = Date.now();
let activeRequestKey: string | null = null;
let requestUrlForLog = payload.url;
try {
const { url, params, requestId, sessionId } = payload;
// Build URL with query parameters
// Xtream API endpoint is always at /player_api.php
const apiUrl = new URL(`${url}/player_api.php`);
Object.entries(params).forEach(([key, value]) => {
apiUrl.searchParams.append(key, value);
});
const apiUrl = buildXtreamApiUrl(url, params);
requestUrlForLog = apiUrl.toString();
const controller = new AbortController();
if (requestId || sessionId) {
@@ -153,10 +171,16 @@ ipcMain.handle(
} catch (error) {
const requestId = payload.requestId;
if (requestId) {
const apiUrl = new URL(`${payload.url}/player_api.php`);
Object.entries(payload.params ?? {}).forEach(([key, value]) => {
apiUrl.searchParams.append(key, value);
});
const apiUrl = (() => {
try {
return buildXtreamApiUrl(
payload.url,
payload.params ?? {}
).toString();
} catch {
return requestUrlForLog;
}
})();
const debugEvent: PortalDebugEvent = {
requestId,
@@ -168,7 +192,7 @@ ipcMain.handle(
status: 'error',
request: {
method: 'GET',
url: apiUrl.toString(),
url: apiUrl,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
@@ -187,7 +211,7 @@ ipcMain.handle(
'[XTREAM_REQUEST] Failed',
formatXtreamError(
error,
payload.url,
requestUrlForLog,
payload.params?.action
)
);
+2 -1
View File
@@ -31,7 +31,8 @@
"PORT": "3333",
"CLIENT_URL": "http://localhost:4200",
"BACKEND_URL": "/api",
"IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS": "1"
"IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS": "1",
"TSX_TSCONFIG_PATH": "tsconfig.base.json"
}
}
},
@@ -256,6 +256,43 @@ https://stream.example/news.m3u8`);
);
});
it('normalizes full Xtream API target URLs before proxying', async () => {
const httpClient = new StubHttpClient();
httpClient.queueResponse({ user_info: { username: 'demo' } });
await withServer(
createWebBackendApp({
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const targetId = await registerProviderTarget(
baseUrl,
'http://xtream.example/panel/player_api.php?username=old&password=old'
);
const response = await fetch(
`${baseUrl}/xtream?targetId=${targetId}&username=%20demo%20&password=%20secret%20&action=get_account_info`
);
await expect(response.json()).resolves.toEqual({
action: 'get_account_info',
payload: { user_info: { username: 'demo' } },
});
expect(httpClient.requests).toEqual([
{
headers: undefined,
params: {
action: 'get_account_info',
password: 'secret',
username: 'demo',
},
url: 'http://xtream.example/panel/player_api.php',
},
]);
}
);
});
it('proxies Stalker requests with MAC cookie and bearer token', async () => {
const httpClient = new StubHttpClient();
httpClient.queueResponse({ js: [{ id: '2001', title: 'Action' }] });
@@ -430,6 +467,36 @@ https://stream.example/news.m3u8`);
);
});
it('rejects Xtream proxy calls when a registered target no longer passes the URL policy', async () => {
const httpClient = new StubHttpClient();
const resolvedAddresses = [['93.184.216.34'], ['127.0.0.1']];
await withServer(
createWebBackendApp({
httpClient,
resolveHostname: async () =>
resolvedAddresses.shift() ?? ['127.0.0.1'],
}),
async (baseUrl) => {
const targetId = await registerProviderTarget(
baseUrl,
'http://xtream.example'
);
const response = await fetch(
`${baseUrl}/xtream?targetId=${targetId}&action=get_account_info`
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
message:
'Provider URL points to a private or local network address',
status: 400,
});
expect(httpClient.requests).toEqual([]);
}
);
});
it('allows private target URLs when explicitly enabled for local self-hosted testing', async () => {
const httpClient = new StubHttpClient();
httpClient.queueResponse({ user_info: { username: 'demo' } });
+40 -2
View File
@@ -7,6 +7,7 @@ import zlib from 'node:zlib';
import axios from 'axios';
import epgParser from 'epg-parser';
import parser from 'iptv-playlist-parser';
import { normalizeXtreamServerUrl } from '@iptvnator/shared/interfaces';
export interface WebBackendHttpGetOptions {
readonly headers?: Record<string, string>;
@@ -176,12 +177,26 @@ export function createWebBackendApp(
});
app.get('/xtream', corsMiddleware, async (req, res) => {
const url = getRegisteredProviderUrl(req, res, providerTargets);
if (!url) {
const registeredUrl = getRegisteredProviderUrl(
req,
res,
providerTargets
);
if (!registeredUrl) {
return;
}
const url = new URL(registeredUrl.href);
try {
const providerUrlError = await normalizeAndValidateXtreamProviderUrl(
url,
providerUrlPolicy
);
if (providerUrlError) {
res.status(providerUrlError.status).json(providerUrlError);
return;
}
// Provider URLs are validated by /provider-targets before they enter the registry.
// codeql[js/request-forgery]
const response = await httpClient.get(
@@ -394,6 +409,29 @@ function appendPathSegment(url: URL, segment: string): string {
return nextUrl.href;
}
async function normalizeAndValidateXtreamProviderUrl(
url: URL,
policy: ProviderUrlPolicy
): Promise<ProviderUrlError | null> {
let normalizedUrl: URL;
try {
normalizedUrl = new URL(normalizeXtreamServerUrl(url.href));
} catch {
return { message: 'Provider URL is not a valid URL', status: 400 };
}
const validatedUrl = await validateProviderUrl(
appendPathSegment(normalizedUrl, 'player_api.php'),
policy
);
if ('message' in validatedUrl) {
return validatedUrl;
}
url.href = normalizedUrl.href;
return null;
}
async function handlePlaylistParse(options: {
readonly guid: () => string;
readonly httpClient: WebBackendHttpClient;
@@ -0,0 +1,74 @@
# Xtream Portal Compatibility
This document captures the Xtream Codes compatibility rules shared by the
Electron and PWA paths.
## Connection Input
Xtream server URLs are normalized through
`normalizeXtreamServerUrl` from `@iptvnator/shared/interfaces`.
Rules:
1. Only `http` and `https` URLs are accepted.
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
reduced to the portal base URL.
6. Provider subpaths are preserved. For example,
`https://example.test/panel/player_api.php?...` becomes
`https://example.test/panel`.
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.
## Account Status
Account status handling uses `resolveXtreamPortalStatus`.
Compatibility rules:
1. Status text is case-insensitive, so `Active`, `active`, and `ACTIVE` are
treated the same.
2. `auth` values `1`, `'1'`, and `true` can mark a response as active when no
status text is present.
3. `auth` values `0`, `'0'`, and `false` mark the response inactive.
4. `exp_date` values `0`, negative numbers, missing values, or invalid values
are treated as no expiry.
5. A past positive `exp_date` marks the account expired even when status is
active.
Status probes try account-info-compatible Xtream variants in this order:
1. `action=get_account_info`
2. no `action`
3. `action=get_profile`
This fallback exists because real panels differ even when they advertise
Xtream Codes compatibility.
## Request Construction
Electron IPC and the PWA backend both construct API requests by appending
`/player_api.php` to the normalized portal base URL. They must not append
`player_api.php` to an already full `player_api.php` or `get.php` URL.
Credentials sent to the API are trimmed before serialization.
The PWA backend only proxies Xtream requests through registered provider
targets. Those targets are validated when registered and revalidated before the
`/xtream` proxy request, including protocol, URL credentials, DNS resolution,
and private-network checks.
## Playback URL Formats
When account info includes `user_info.allowed_output_formats`, the current
Xtream playlist keeps those formats for the active session. Live stream URL
construction falls back to the first provider-allowed format when the selected
application format is not allowed by the portal.
If stored Xtream playback credentials contain an invalid server URL or blank
username/password, stream URL construction returns an empty URL instead of
throwing during playback.
@@ -0,0 +1,92 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { PortalStatusService } 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 };
beforeEach(() => {
store = {
dispatch: jest.fn(),
};
portalStatusService = {
checkPortalStatus: jest.fn().mockResolvedValue('active'),
};
TestBed.configureTestingModule({
providers: [
{ provide: Store, useValue: store },
{ provide: PortalStatusService, useValue: portalStatusService },
],
});
component = TestBed.runInInjectionContext(
() => new XtreamCodeImportComponent()
);
});
it('rejects file URLs for Xtream portals', () => {
component.form.patchValue({
title: 'Portal',
serverUrl: 'file://example.com/portal',
username: 'user',
password: 'pass',
});
expect(component.form.valid).toBe(false);
});
it('rejects URLs with inline credentials before add or test actions', async () => {
component.form.patchValue({
title: 'Portal',
serverUrl: 'https://user:pass@example.com',
username: 'user',
password: 'pass',
});
expect(component.form.valid).toBe(false);
await component.testConnection();
component.addPlaylist();
expect(component.isTestingConnection).toBe(false);
expect(portalStatusService.checkPortalStatus).not.toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalled();
});
it('extracts and trims username and password from a full Xtream URL', () => {
component.extractParams(
'https://example.com/get.php?username=%20user%20&password=%20pass%20&type=m3u_plus'
);
expect(component.form.get('username')?.value).toBe('user');
expect(component.form.get('password')?.value).toBe('pass');
});
it('normalizes full Xtream playlist URLs when adding a portal', () => {
component.form.patchValue({
title: 'Portal',
serverUrl:
' https://example.com/base/get.php?username=user&password=pass&type=m3u_plus ',
username: ' user ',
password: ' pass ',
});
component.addPlaylist();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.addPlaylist({
playlist: expect.objectContaining({
password: 'pass',
serverUrl: 'https://example.com/base',
title: 'Portal',
username: 'user',
}),
})
);
});
});
@@ -1,9 +1,11 @@
import { Component, EventEmitter, Output, inject } from '@angular/core';
import {
AbstractControl,
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
ValidationErrors,
Validators,
} from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -13,9 +15,29 @@ import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { PortalStatus, PortalStatusService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import {
extractXtreamCredentialsFromUrl,
normalizeXtreamServerUrl,
Playlist,
} from '@iptvnator/shared/interfaces';
import { v4 as uuid } from 'uuid';
function xtreamServerUrlValidator(
control: AbstractControl
): ValidationErrors | null {
const value = control.value;
if (typeof value !== 'string' || value.trim().length === 0) {
return null;
}
try {
normalizeXtreamServerUrl(value);
return null;
} catch {
return { xtreamServerUrl: true };
}
}
@Component({
imports: [
FormsModule,
@@ -66,7 +88,7 @@ import { v4 as uuid } from 'uuid';
})
export class XtreamCodeImportComponent {
@Output() addClicked = new EventEmitter<void>();
URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
URL_REGEX = /^\s*https?:\/\/[^ "]+\s*$/;
form = new FormGroup({
_id: new FormControl(uuid()),
@@ -76,6 +98,7 @@ export class XtreamCodeImportComponent {
serverUrl: new FormControl('', [
Validators.required,
Validators.pattern(this.URL_REGEX),
xtreamServerUrlValidator,
]),
importDate: new FormControl(new Date().toISOString()),
});
@@ -89,22 +112,22 @@ export class XtreamCodeImportComponent {
async testConnection(): Promise<void> {
if (!this.form.valid) return;
this.isTestingConnection = true;
const serverUrlAsString = this.form.value.serverUrl as string;
const url = new URL(serverUrlAsString);
const serverUrl = `${url.protocol}//${url.hostname}${
url.port ? ':' + url.port : ''
}`;
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(
serverUrl,
this.form.value.username as string,
this.form.value.password as string,
connection.serverUrl,
connection.username,
connection.password,
{ skipCache: true }
);
} finally {
@@ -137,16 +160,20 @@ export class XtreamCodeImportComponent {
}
addPlaylist() {
const serverUrlAsString = this.form.value.serverUrl as string;
const url = new URL(serverUrlAsString);
const serverUrl = `${url.protocol}//${url.hostname}${
url.port ? ':' + url.port : ''
}`;
if (!this.form.valid) return;
const connection = this.getNormalizedConnection();
if (!connection) {
return;
}
this.store.dispatch(
PlaylistActions.addPlaylist({
playlist: {
...this.form.value,
serverUrl,
password: connection.password,
serverUrl: connection.serverUrl,
username: connection.username,
} as Playlist,
})
);
@@ -160,17 +187,33 @@ export class XtreamCodeImportComponent {
)
return;
try {
// Create a new URL object from the complete link
const url = new URL(urlAsString);
const credentials = extractXtreamCredentialsFromUrl(urlAsString);
if (!credentials) {
return;
}
// Extract username and password from query parameters
const username = url.searchParams.get('username') || '';
const password = url.searchParams.get('password') || '';
this.form.get('username')?.setValue(username);
this.form.get('password')?.setValue(password);
this.form.get('username')?.setValue(credentials.username);
this.form.get('password')?.setValue(credentials.password);
} catch (error) {
console.error('Invalid URL', error);
}
}
private getNormalizedConnection(): {
password: string;
serverUrl: string;
username: string;
} | null {
try {
return {
password: (this.form.value.password as string).trim(),
serverUrl: normalizeXtreamServerUrl(
this.form.value.serverUrl as string
),
username: (this.form.value.username as string).trim(),
};
} catch {
return null;
}
}
}
@@ -159,6 +159,78 @@ describe('PlaylistInfoComponent', () => {
expect(dialogRef.close).toHaveBeenCalledTimes(1);
});
it('normalizes edited Xtream playlist credentials before saving metadata', async () => {
const xtreamPlaylist = {
...playlist,
title: 'Old Xtream',
serverUrl: 'http://old.example:8080',
username: 'old-user',
password: 'old-pass',
url: undefined,
} as Playlist & { id: string };
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: xtreamPlaylist,
});
createComponent();
await component.saveChanges({
_id: 'playlist-1',
title: 'Updated Xtream',
serverUrl:
' http://new.example:8080/live/player_api.php?username=ignored&password=ignored ',
username: ' new-user ',
password: ' new-pass ',
});
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: 'playlist-1',
title: 'Updated Xtream',
serverUrl: 'http://new.example:8080/live',
username: 'new-user',
password: 'new-pass',
},
})
);
});
it('normalizes edited Xtream playlist details before updating the Electron database', async () => {
const xtreamPlaylist = {
...playlist,
title: 'Old Xtream',
serverUrl: 'http://old.example:8080',
username: 'old-user',
password: 'old-pass',
url: undefined,
} as Playlist & { id: string };
runtime.supportsXtreamSqliteDataSource = true;
databaseService.updateXtreamPlaylistDetails.mockResolvedValue(true);
TestBed.overrideProvider(MAT_DIALOG_DATA, {
useValue: xtreamPlaylist,
});
createComponent();
await component.saveChanges({
_id: 'playlist-1',
title: 'Updated Xtream',
serverUrl:
' http://new.example:8080/get.php?username=ignored&password=ignored&type=m3u_plus&output=ts ',
username: ' new-user ',
password: ' new-pass ',
});
expect(
databaseService.updateXtreamPlaylistDetails
).toHaveBeenCalledWith({
id: 'playlist-1',
title: 'Updated Xtream',
serverUrl: 'http://new.example:8080',
username: 'new-user',
password: 'new-pass',
});
});
it('uses the Electron save dialog when desktop file saving is available', async () => {
runtime.isElectron = true;
runtime.supportsDesktopFileSave = true;
@@ -28,7 +28,11 @@ import {
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
normalizeXtreamServerUrl,
Playlist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
type DesktopFileSaveBridge = Pick<
typeof window.electron,
@@ -160,6 +164,8 @@ export class PlaylistInfoComponent {
async saveChanges(playlist: PlaylistMeta): Promise<void> {
try {
const normalizedPlaylist =
this.normalizeXtreamPlaylistMeta(playlist);
const isXtream =
this.playlist &&
this.playlist.username &&
@@ -167,12 +173,14 @@ export class PlaylistInfoComponent {
this.playlist.serverUrl;
if (isXtream && this.runtime.supportsXtreamSqliteDataSource) {
await this.updateXtreamPlaylist(playlist);
await this.updateXtreamPlaylist(normalizedPlaylist);
}
// Dispatch store action to update UI
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({ playlist })
PlaylistActions.updatePlaylistMeta({
playlist: normalizedPlaylist,
})
);
this.snackBar.open(
@@ -195,6 +203,19 @@ export class PlaylistInfoComponent {
}
}
private normalizeXtreamPlaylistMeta(playlist: PlaylistMeta): PlaylistMeta {
if (!playlist.serverUrl || !playlist.username || !playlist.password) {
return playlist;
}
return {
...playlist,
password: playlist.password.trim(),
serverUrl: normalizeXtreamServerUrl(playlist.serverUrl),
username: playlist.username.trim(),
};
}
async updateXtreamPlaylist(playlist: PlaylistMeta) {
const success = await this.databaseService.updateXtreamPlaylistDetails({
id: this.playlist._id,
@@ -33,6 +33,7 @@ export interface XtreamPlaylistData {
referrer?: string;
origin?: string;
serverTimezone?: string;
allowedOutputFormats?: string[];
}
/**
@@ -30,6 +30,91 @@ describe('XtreamApiService', () => {
service = TestBed.inject(XtreamApiService);
});
it('normalizes account-info server URLs and trims credentials before IPC', async () => {
dataService.sendIpcEvent.mockResolvedValue({
payload: {
user_info: {
auth: 1,
exp_date: '0',
status: 'Active',
},
server_info: {},
},
});
await service.getAccountInfo({
serverUrl:
' https://demo.example/base/player_api.php?username=old&password=old ',
username: ' demo ',
password: ' secret ',
});
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
XTREAM_REQUEST,
expect.objectContaining({
url: 'https://demo.example/base',
params: {
action: 'get_account_info',
password: 'secret',
username: 'demo',
},
})
);
});
it('falls back through Xtream account actions until user_info is returned', async () => {
dataService.sendIpcEvent.mockImplementation(
async (_type: string, payload: unknown) => {
const action = (
payload as {
params: { action?: string };
}
).params.action;
if (action === 'get_profile') {
return {
payload: {
user_info: {
auth: 1,
exp_date: '0',
status: 'Active',
},
server_info: {},
},
};
}
return { payload: { server_info: {} } };
}
);
const response = await service.getAccountInfo(credentials);
expect(response.user_info.auth).toBe(1);
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(3);
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
2,
XTREAM_REQUEST,
expect.objectContaining({
params: {
password: 'secret',
username: 'demo',
},
})
);
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
3,
XTREAM_REQUEST,
expect.objectContaining({
params: {
action: 'get_profile',
password: 'secret',
username: 'demo',
},
})
);
});
it('falls back to the legacy full-epg action and normalizes the response', async () => {
dataService.sendIpcEvent.mockImplementation(
async (_type: string, payload: unknown) => {
@@ -52,9 +137,10 @@ describe('XtreamApiService', () => {
title: Buffer.from('Later Show').toString(
'base64'
),
description: Buffer.from(
'Later description'
).toString('base64'),
description:
Buffer.from('Later description').toString(
'base64'
),
start: '2026-04-04 11:00:00',
end: '2026-04-04 11:30:00',
start_timestamp: '1775300400',
@@ -245,7 +331,9 @@ describe('XtreamApiService', () => {
expect(shortItems[0].start).toBe(fullItems[0].start);
expect(shortItems[0].stop).toBe(fullItems[0].stop);
expect(shortItems[0].start_timestamp).toBe(fullItems[0].start_timestamp);
expect(shortItems[0].start_timestamp).toBe(
fullItems[0].start_timestamp
);
expect(shortItems[0].stop_timestamp).toBe(fullItems[0].stop_timestamp);
});
});
@@ -10,6 +10,7 @@ import {
XtreamVodDetails,
XtreamVodStream,
XTREAM_REQUEST,
normalizeXtreamServerUrl,
} from '@iptvnator/shared/interfaces';
import { XtreamAccountInfo } from '../account-info/account-info.interface';
@@ -17,6 +18,7 @@ import { XtreamAccountInfo } from '../account-info/account-info.interface';
* Xtream API credentials
*/
export interface XtreamCredentials {
allowedOutputFormats?: string[];
serverUrl: string;
username: string;
password: string;
@@ -38,6 +40,12 @@ export interface XtreamRequestOptions {
suppressErrorLog?: boolean;
}
const XTREAM_ACCOUNT_ACTIONS = [
XtreamCodeActions.GetAccountInfo,
null,
'get_profile',
] as const;
/**
* Raw EPG listing from API (before decoding)
*/
@@ -72,7 +80,10 @@ export class XtreamApiService {
private readonly dataService = inject(DataService);
async cancelSession(sessionId: string): Promise<boolean> {
if (!sessionId || typeof window.electron?.xtreamCancelSession !== 'function') {
if (
!sessionId ||
typeof window.electron?.xtreamCancelSession !== 'function'
) {
return false;
}
@@ -92,15 +103,38 @@ export class XtreamApiService {
credentials: XtreamCredentials,
options?: XtreamRequestOptions
): Promise<XtreamAccountInfo> {
return this.sendRequest(
credentials.serverUrl,
{
username: credentials.username,
password: credentials.password,
action: XtreamCodeActions.GetAccountInfo,
},
options
);
let lastError: unknown;
let lastResponse: XtreamAccountInfo | null = null;
for (const action of XTREAM_ACCOUNT_ACTIONS) {
try {
const response = await this.sendRequest<XtreamAccountInfo>(
credentials.serverUrl,
{
...(action ? { action } : {}),
username: credentials.username,
password: credentials.password,
},
options
);
if (this.hasAccountInfoPayload(response)) {
return response;
}
lastResponse = response;
} catch (error) {
lastError = error;
}
}
if (lastResponse) {
return lastResponse;
}
throw lastError instanceof Error
? lastError
: new Error('Failed to fetch Xtream account info');
}
/**
@@ -324,7 +358,8 @@ export class XtreamApiService {
Array.prototype.map
.call(atob(str), (c: string) => {
return (
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
'%' +
('00' + c.charCodeAt(0).toString(16)).slice(-2)
);
})
.join('')
@@ -334,7 +369,9 @@ export class XtreamApiService {
}
}
private getEpgListings(response: EpgResponse | null | undefined): RawEpgListing[] {
private getEpgListings(
response: EpgResponse | null | undefined
): RawEpgListing[] {
const listings = response?.epg_listings;
if (!listings) {
return [];
@@ -384,7 +421,10 @@ export class XtreamApiService {
.filter((item) => Boolean(item.start) && Boolean(item.stop))
.sort(
(left, right) =>
this.getEpgItemTimestampMs(left.start, left.start_timestamp) -
this.getEpgItemTimestampMs(
left.start,
left.start_timestamp
) -
this.getEpgItemTimestampMs(
right.start,
right.start_timestamp
@@ -429,7 +469,10 @@ export class XtreamApiService {
.filter((item) => Boolean(item.start) && Boolean(item.stop))
.sort(
(left, right) =>
this.getEpgItemTimestampMs(left.start, left.start_timestamp) -
this.getEpgItemTimestampMs(
left.start,
left.start_timestamp
) -
this.getEpgItemTimestampMs(
right.start,
right.start_timestamp
@@ -478,13 +521,18 @@ export class XtreamApiService {
params: Record<string, string | number>,
options?: XtreamRequestOptions
): Promise<TResponse> {
const normalizedUrl = normalizeXtreamServerUrl(url);
const serializedParams: Record<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
serializedParams[key] = String(value);
const serializedValue = String(value);
serializedParams[key] =
key === 'username' || key === 'password'
? serializedValue.trim()
: serializedValue;
});
const response = (await this.dataService.sendIpcEvent(XTREAM_REQUEST, {
url,
url: normalizedUrl,
params: serializedParams,
requestId: options?.requestId,
sessionId: options?.sessionId,
@@ -498,10 +546,19 @@ export class XtreamApiService {
// The IPC layer catches errors and returns { type: 'ERROR', message, status }
// instead of rejecting. Convert that back into a thrown error so callers
// can handle it with .catch() / try-catch.
if (response?.type === 'ERROR' || (!response?.payload && response?.message)) {
if (
response?.type === 'ERROR' ||
(!response?.payload && response?.message)
) {
throw new Error(response?.message ?? 'Request failed');
}
return response?.payload as TResponse;
}
private hasAccountInfoPayload(
response: XtreamAccountInfo | null | undefined
): boolean {
return Boolean(response?.user_info);
}
}
@@ -1,6 +1,10 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { DatabaseService, SettingsStore } from '@iptvnator/services';
import {
XtreamSerieEpisode,
XtreamVodDetails,
} from '@iptvnator/shared/interfaces';
import { XtreamCredentials } from './xtream-api.service';
import { XtreamUrlService } from './xtream-url.service';
@@ -44,6 +48,66 @@ describe('XtreamUrlService', () => {
window.electron = originalElectron;
});
it('normalizes portal base URLs and trims credentials for live streams', () => {
const url = service.constructLiveUrl(
{
serverUrl: ' https://demo.example/base/ ',
username: ' demo ',
password: ' secret ',
},
101
);
expect(url).toBe('https://demo.example/base/live/demo/secret/101.ts');
});
it('uses the first allowed provider output format when the selected live format is not allowed', () => {
const url = service.constructLiveUrl(
{
...credentials,
allowedOutputFormats: ['m3u8'],
},
101
);
expect(url).toBe('http://demo.example/live/demo/secret/101.m3u8');
});
it('returns empty stream URLs instead of throwing for invalid stored server URLs', () => {
const invalidCredentials: XtreamCredentials = {
...credentials,
serverUrl: 'https://demo:secret@demo.example',
};
const vodItem: XtreamVodDetails = {
movie_data: {
added: '',
category_id: '',
container_extension: 'mp4',
custom_sid: null,
direct_source: '',
name: 'Movie',
stream_id: 101,
},
};
const episode: XtreamSerieEpisode = {
added: '',
container_extension: 'mp4',
custom_sid: '',
direct_source: '',
episode_num: 1,
id: '202',
info: [],
season: 1,
title: 'Episode',
};
expect(service.constructLiveUrl(invalidCredentials, 101)).toBe('');
expect(service.constructVodUrl(invalidCredentials, vodItem)).toBe('');
expect(service.constructEpisodeUrl(invalidCredentials, episode)).toBe(
''
);
});
it('detects the legacy catchup scheme once and then uses the cached result', async () => {
const xtreamProbeUrl = jest
.fn()
@@ -1,5 +1,6 @@
import { inject, Injectable } from '@angular/core';
import {
normalizeXtreamServerUrl,
XtreamSerieEpisode,
XtreamVodDetails,
} from '@iptvnator/shared/interfaces';
@@ -32,6 +33,14 @@ type XtreamVodStreamLike = XtreamVodDetails & {
type XtreamCatchupScheme = 'rest' | 'legacy';
interface NormalizedXtreamCredentials {
password: string;
rawPassword: string;
rawUsername: string;
serverUrl: string;
username: string;
}
type XtreamProbeApi = {
xtreamProbeUrl?: (
url: string,
@@ -49,7 +58,10 @@ const XTREAM_CATCHUP_SCHEME_KEY_PREFIX = 'xtream-catchup-scheme:';
export class XtreamUrlService {
private readonly databaseService = inject(DatabaseService);
private readonly settingsStore = inject(SettingsStore);
private readonly catchupSchemeCache = new Map<string, XtreamCatchupScheme>();
private readonly catchupSchemeCache = new Map<
string,
XtreamCatchupScheme
>();
private readonly catchupSchemeRequests = new Map<
string,
Promise<XtreamCatchupScheme>
@@ -64,9 +76,16 @@ export class XtreamUrlService {
xtreamId: number,
format?: string
): string {
const streamFormat =
format ?? this.settingsStore.streamFormat() ?? 'ts';
return `${credentials.serverUrl}/live/${credentials.username}/${credentials.password}/${xtreamId}.${streamFormat}`;
const normalizedCredentials = this.normalizeCredentials(credentials);
if (!normalizedCredentials) {
return '';
}
const streamFormat = this.resolveLiveStreamFormat(
credentials,
format ?? this.settingsStore.streamFormat() ?? 'ts'
);
return `${normalizedCredentials.serverUrl}/live/${normalizedCredentials.username}/${normalizedCredentials.password}/${xtreamId}.${streamFormat}`;
}
/**
@@ -83,7 +102,12 @@ export class XtreamUrlService {
if (!streamId || !extension) {
return '';
}
return `${credentials.serverUrl}/movie/${credentials.username}/${credentials.password}/${streamId}.${extension}`;
const normalizedCredentials = this.normalizeCredentials(credentials);
if (!normalizedCredentials) {
return '';
}
return `${normalizedCredentials.serverUrl}/movie/${normalizedCredentials.username}/${normalizedCredentials.password}/${streamId}.${extension}`;
}
/**
@@ -94,7 +118,12 @@ export class XtreamUrlService {
credentials: XtreamCredentials,
episode: XtreamSerieEpisode
): string {
return `${credentials.serverUrl}/series/${credentials.username}/${credentials.password}/${episode.id}.${episode.container_extension}`;
const normalizedCredentials = this.normalizeCredentials(credentials);
if (!normalizedCredentials) {
return '';
}
return `${normalizedCredentials.serverUrl}/series/${normalizedCredentials.username}/${normalizedCredentials.password}/${episode.id}.${episode.container_extension}`;
}
constructCatchupUrl(
@@ -105,6 +134,11 @@ export class XtreamUrlService {
scheme: XtreamCatchupScheme,
serverTimezone?: string
): string {
const normalizedCredentials = this.normalizeCredentials(credentials);
if (!normalizedCredentials) {
return '';
}
const durationMinutes = Math.max(
1,
Math.round((stopTimestamp - startTimestamp) / 60)
@@ -116,16 +150,16 @@ export class XtreamUrlService {
if (scheme === 'legacy') {
const params = new URLSearchParams({
username: credentials.username,
password: credentials.password,
username: normalizedCredentials.rawUsername,
password: normalizedCredentials.rawPassword,
stream: String(streamId),
start: timeString,
duration: String(durationMinutes),
});
return `${credentials.serverUrl}/streaming/timeshift.php?${params.toString()}`;
return `${normalizedCredentials.serverUrl}/streaming/timeshift.php?${params.toString()}`;
}
return `${credentials.serverUrl}/timeshift/${credentials.username}/${credentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`;
return `${normalizedCredentials.serverUrl}/timeshift/${normalizedCredentials.username}/${normalizedCredentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`;
}
async resolveCatchupUrl(
@@ -220,6 +254,10 @@ export class XtreamUrlService {
serverTimezone
);
if (!restUrl || !legacyUrl) {
return 'rest';
}
const restStatus = await this.probeCatchupUrl(restUrl);
let detectedScheme: XtreamCatchupScheme;
@@ -264,6 +302,47 @@ export class XtreamUrlService {
);
}
private normalizeCredentials(
credentials: XtreamCredentials
): NormalizedXtreamCredentials | null {
const rawUsername = credentials.username.trim();
const rawPassword = credentials.password.trim();
if (!rawUsername || !rawPassword) {
return null;
}
let serverUrl: string;
try {
serverUrl = normalizeXtreamServerUrl(credentials.serverUrl);
} catch {
return null;
}
return {
password: encodeURIComponent(rawPassword),
rawPassword,
rawUsername,
serverUrl,
username: encodeURIComponent(rawUsername),
};
}
private resolveLiveStreamFormat(
credentials: XtreamCredentials,
requestedFormat: string
): string {
const requested = requestedFormat.trim();
const allowedFormats = credentials.allowedOutputFormats
?.map((format) => format.trim())
.filter(Boolean);
if (allowedFormats?.length && !allowedFormats.includes(requested)) {
return allowedFormats[0];
}
return requested;
}
private formatCatchupStartTime(
timestamp: number,
timezone?: string
@@ -146,12 +146,7 @@ const initialContentState: ContentState = {
export function withContent() {
const logger = createLogger('withContent');
type ParentPortalStoreLike = {
currentPlaylist?: () => {
id?: string;
password: string;
serverUrl: string;
username: string;
} | null;
currentPlaylist?: () => (XtreamCredentials & { id?: string }) | null;
playlistId?: () => string | null;
portalStatus?: () => PortalStatusType;
checkPortalStatus?: () => Promise<PortalStatusType>;
@@ -374,6 +369,7 @@ export function withContent() {
return {
playlistId,
credentials: {
allowedOutputFormats: playlist.allowedOutputFormats,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
@@ -386,7 +382,10 @@ export function withContent() {
type: ContentType
): Promise<boolean> => {
const [hasCategories, hasContent] = await Promise.all([
dataSource.hasCategories(playlistId, toDbCategoryType(type)),
dataSource.hasCategories(
playlistId,
toDbCategoryType(type)
),
dataSource.hasContent(playlistId, toStreamType(type)),
]);
@@ -399,10 +398,17 @@ export function withContent() {
): Promise<boolean> => {
const types = getTypesForCacheScope(scope);
if (scope === 'search' || scope === 'recently-added' || !scope) {
if (
scope === 'search' ||
scope === 'recently-added' ||
!scope
) {
const checks = await Promise.all(
types.map((type) =>
dataSource.hasContent(playlistId, toStreamType(type))
dataSource.hasContent(
playlistId,
toStreamType(type)
)
)
);
return checks.some(Boolean);
@@ -476,10 +482,7 @@ export function withContent() {
);
} catch (error) {
if (
!isCurrentCachedHydrationContext(
playlistId,
generation
)
!isCurrentCachedHydrationContext(playlistId, generation)
) {
return;
}
@@ -505,9 +508,7 @@ export function withContent() {
throw error;
}
if (
!isCurrentCachedHydrationContext(playlistId, generation)
) {
if (!isCurrentCachedHydrationContext(playlistId, generation)) {
return;
}
@@ -572,10 +573,7 @@ export function withContent() {
return;
}
const requestKey = getCachedHydrationKey(
ctx.playlistId,
scope
);
const requestKey = getCachedHydrationKey(ctx.playlistId, scope);
const inFlightRequest =
activeCachedHydrationPromises.get(requestKey);
@@ -634,7 +632,7 @@ export function withContent() {
: state.activeImportOperationIds.includes(
operationId
)
? state.activeImportOperationIds
? state.activeImportOperationIds
: [
...state.activeImportOperationIds,
operationId,
@@ -7,10 +7,7 @@ import {
withState,
} from '@ngrx/signals';
import { EpgItem } from '@iptvnator/shared/interfaces';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
XtreamApiService,
XtreamCredentials,
@@ -43,11 +40,7 @@ const initialEpgState: EpgState = {
export function withEpg() {
const logger = createLogger('withEpg');
type ParentSelectionStoreLike = {
currentPlaylist?: () => {
password: string;
serverUrl: string;
username: string;
} | null;
currentPlaylist?: () => XtreamCredentials | null;
selectedItem?: () => {
xtream_id?: number | null;
epg_channel_id?: string | null;
@@ -101,6 +94,7 @@ export function withEpg() {
}
return {
allowedOutputFormats: playlist.allowedOutputFormats,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
@@ -143,8 +137,9 @@ export function withEpg() {
const storeAny = store as ParentSelectionStoreLike;
const selectedItem = storeAny.selectedItem?.();
const xtreamId = selectedItem?.xtream_id;
if (!selectedItem?.xtream_id) {
if (!xtreamId) {
patchState(store, { epgItems: [] });
return [];
}
@@ -157,10 +152,7 @@ export function withEpg() {
epgChannelId: selectedItem.epg_channel_id,
preferUploaded: preferUploaded(),
fetchProvider: () =>
fetchFullProvider(
credentials,
selectedItem.xtream_id!
),
fetchFullProvider(credentials, xtreamId),
});
patchState(store, {
@@ -74,6 +74,7 @@ export function withPlayer() {
}
return {
allowedOutputFormats: playlist.allowedOutputFormats,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
@@ -0,0 +1,103 @@
import { TestBed } from '@angular/core/testing';
import { signalStore } from '@ngrx/signals';
import {
XTREAM_DATA_SOURCE,
XtreamPlaylistData,
} from '../../data-sources/xtream-data-source.interface';
import { XtreamApiService } from '../../services/xtream-api.service';
import { withPortal } from './with-portal.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
error: jest.fn(),
}),
}));
const PLAYLIST: XtreamPlaylistData = {
id: 'playlist-1',
name: 'Portal',
password: 'pass',
serverUrl: 'https://example.com',
type: 'xtream',
username: 'user',
};
const TestPortalStore = signalStore(withPortal());
describe('withPortal', () => {
let store: InstanceType<typeof TestPortalStore>;
let apiService: {
getAccountInfo: jest.Mock;
};
beforeEach(() => {
apiService = {
getAccountInfo: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
TestPortalStore,
{
provide: XtreamApiService,
useValue: apiService,
},
{
provide: XTREAM_DATA_SOURCE,
useValue: {
getPlaylist: jest.fn(),
},
},
],
});
store = TestBed.inject(TestPortalStore);
store.setCurrentPlaylist(PLAYLIST);
});
it('accepts lowercase active account status and unlimited expiration', async () => {
apiService.getAccountInfo.mockResolvedValue({
user_info: {
auth: 1,
exp_date: '0',
status: 'active',
},
});
await expect(store.checkPortalStatus()).resolves.toBe('active');
expect(store.portalStatus()).toBe('active');
});
it('stores allowed output formats from account info on the current playlist', async () => {
apiService.getAccountInfo.mockResolvedValue({
user_info: {
allowed_output_formats: ['m3u8'],
auth: 1,
exp_date: '0',
status: 'Active',
},
});
await store.checkPortalStatus();
expect(store.currentPlaylist()?.allowedOutputFormats).toEqual(['m3u8']);
});
it('clears stale allowed output formats when account info omits them', async () => {
store.setCurrentPlaylist({
...PLAYLIST,
allowedOutputFormats: ['m3u8'],
});
apiService.getAccountInfo.mockResolvedValue({
user_info: {
auth: 1,
exp_date: '0',
status: 'Active',
},
});
await store.checkPortalStatus();
expect(store.currentPlaylist()?.allowedOutputFormats).toBeUndefined();
});
});
@@ -15,6 +15,7 @@ import {
} from '../../services/xtream-api.service';
import { PortalStatusType } from '../../xtream-state';
import { createLogger } from '@iptvnator/portal/shared/util';
import { resolveXtreamPortalStatus } from '@iptvnator/shared/interfaces';
/**
* Portal state for managing playlist and portal status
@@ -44,30 +45,6 @@ const initialPortalState: PortalState = {
export function withPortal() {
const logger = createLogger('withPortal');
const resolvePortalStatus = (response: {
user_info?: {
exp_date?: string;
status?: string;
};
} | null): PortalStatusType => {
if (!response?.user_info?.status) {
return 'unavailable';
}
if (response.user_info.status === 'Active') {
if (!response.user_info.exp_date) {
return 'active';
}
const expDate = new Date(
parseInt(response.user_info.exp_date, 10) * 1000
);
return expDate < new Date() ? 'expired' : 'active';
}
return 'inactive';
};
return signalStoreFeature(
withState<PortalState>(initialPortalState),
@@ -139,20 +116,28 @@ export function withPortal() {
try {
const response =
await apiService.getAccountInfo(credentials);
const portalStatus = resolvePortalStatus(response);
const portalStatus =
resolveXtreamPortalStatus(response);
const serverTimezone =
response?.server_info?.timezone ?? undefined;
const allowedOutputFormats = response?.user_info
?.allowed_output_formats?.length
? response.user_info.allowed_output_formats
.map((format) => format.trim())
.filter(Boolean)
: undefined;
patchState(store, { portalStatus });
if (serverTimezone) {
const current = store.currentPlaylist();
if (current) {
patchState(store, {
currentPlaylist: {
...current,
serverTimezone,
},
});
}
const current = store.currentPlaylist();
if (current) {
patchState(store, {
currentPlaylist: {
...current,
allowedOutputFormats,
...(serverTimezone
? { serverTimezone }
: {}),
},
});
}
return portalStatus;
} catch (error) {
@@ -92,4 +92,24 @@ describe('AccountInfoComponent', () => {
'-',
]);
});
it('treats account status as active regardless of provider casing', () => {
component.accountInfo.set({
user_info: {
active_cons: '0',
allowed_output_formats: [],
exp_date: '0',
max_connections: '0',
status: 'active',
username: 'dialog-user',
},
server_info: {
server_protocol: 'http',
url: 'dialog.example.test',
},
});
expect(component.isActive()).toBe(true);
expect(component.userDetails()[0]?.tone).toBe('positive');
});
});
@@ -15,7 +15,10 @@ import {
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
import type { XtreamAccountInfoDialogData } from '@iptvnator/shared/interfaces';
import {
resolveXtreamPortalStatus,
type XtreamAccountInfoDialogData,
} from '@iptvnator/shared/interfaces';
type AccountLoadState = 'loading' | 'ready' | 'error';
@@ -64,7 +67,7 @@ export class AccountInfoComponent {
readonly skeletonPanels = [1, 2];
readonly isActive = computed(
() => this.accountInfo()?.user_info?.status === 'Active'
() => resolveXtreamPortalStatus(this.accountInfo()) === 'active'
);
readonly isTrial = computed(
() => this.accountInfo()?.user_info?.is_trial === '1'
@@ -15,12 +15,15 @@ describe('PortalStatusService', () => {
dataService = {
sendIpcEvent: jest.fn(),
};
const injector = createEnvironmentInjector([
{
provide: DataService,
useValue: dataService,
},
], Injector.NULL as unknown as EnvironmentInjector);
const injector = createEnvironmentInjector(
[
{
provide: DataService,
useValue: dataService,
},
],
Injector.NULL as unknown as EnvironmentInjector
);
service = runInInjectionContext(
injector,
@@ -75,4 +78,129 @@ describe('PortalStatusService', () => {
consoleErrorSpy.mockRestore();
});
it('normalizes full playlist URLs and trims copied credentials before checking status', async () => {
dataService.sendIpcEvent.mockResolvedValue({
payload: {
user_info: {
auth: 1,
status: 'Active',
exp_date: '0',
},
},
});
const status = await service.checkPortalStatus(
' https://example.com/get.php?username=old&password=old&type=m3u_plus ',
' user ',
' pass '
);
expect(status).toBe('active');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
'XTREAM_REQUEST',
expect.objectContaining({
url: 'https://example.com',
params: {
action: 'get_account_info',
password: 'pass',
username: 'user',
},
})
);
});
it('reads cached status with the same normalized connection key used by checks', async () => {
dataService.sendIpcEvent.mockResolvedValue({
payload: {
user_info: {
auth: 1,
exp_date: '0',
status: 'Active',
},
},
});
await service.checkPortalStatus(
' https://example.com/get.php?username=old&password=old&type=m3u_plus ',
' user ',
' pass '
);
expect(
service.getCachedStatus(
' https://example.com/get.php?username=old&password=old&type=m3u_plus ',
' user ',
' pass '
)
).toBe('active');
});
it('falls back to alternate account actions when get_account_info does not return user info', async () => {
dataService.sendIpcEvent.mockImplementation(
async (_type: string, payload: unknown) => {
const action = (
payload as {
params: { action?: string };
}
).params.action;
if (action === 'get_profile') {
return {
payload: {
user_info: {
auth: 1,
exp_date: '0',
},
},
};
}
return { payload: { server_info: {} } };
}
);
await expect(
service.checkPortalStatus('https://example.com', 'user', 'pass')
).resolves.toBe('active');
expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(3);
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
2,
'XTREAM_REQUEST',
expect.objectContaining({
params: {
password: 'pass',
username: 'user',
},
})
);
expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith(
3,
'XTREAM_REQUEST',
expect.objectContaining({
params: {
action: 'get_profile',
password: 'pass',
username: 'user',
},
})
);
});
it('treats lowercase active status and exp_date 0 as active', async () => {
dataService.sendIpcEvent.mockResolvedValue({
payload: {
user_info: {
auth: 1,
exp_date: '0',
status: 'active',
},
},
});
await expect(
service.checkPortalStatus('https://example.com', 'user', 'pass')
).resolves.toBe('active');
});
});
+92 -46
View File
@@ -1,4 +1,9 @@
import { Injectable, inject } from '@angular/core';
import {
normalizeXtreamServerUrl,
resolveXtreamPortalStatus,
XtreamPortalStatusResponseLike,
} from '@iptvnator/shared/interfaces';
import { DataService } from './data.service';
export type PortalStatus =
@@ -9,12 +14,7 @@ export type PortalStatus =
| 'checking';
interface XtreamPortalStatusResponse {
payload?: {
user_info?: {
status?: string;
exp_date?: string;
};
};
payload?: XtreamPortalStatusResponseLike;
}
interface PortalStatusCacheEntry {
@@ -32,6 +32,11 @@ interface CheckPortalStatusOptions {
}
const PORTAL_STATUS_CACHE_TTL_MS = 30_000;
const XTREAM_STATUS_ACTIONS = [
'get_account_info',
null,
'get_profile',
] as const;
@Injectable({
providedIn: 'root',
@@ -70,7 +75,20 @@ export class PortalStatusService {
password: string,
options?: CheckPortalStatusOptions
): Promise<PortalStatus> {
const cacheKey = this.buildCacheKey(serverUrl, username, password);
const connection = this.normalizeConnection(
serverUrl,
username,
password
);
if (!connection) {
return 'unavailable';
}
const cacheKey = this.buildCacheKey(
connection.serverUrl,
connection.username,
connection.password
);
if (!options?.skipCache) {
const cached = this.cache.get(cacheKey);
@@ -87,7 +105,11 @@ export class PortalStatusService {
}
}
const request = this.fetchPortalStatus(serverUrl, username, password)
const request = this.fetchPortalStatus(
connection.serverUrl,
connection.username,
connection.password
)
.then((status) => {
this.cache.set(cacheKey, {
status,
@@ -115,8 +137,21 @@ export class PortalStatusService {
username: string,
password: string
): PortalStatus | null {
const connection = this.normalizeConnection(
serverUrl,
username,
password
);
if (!connection) {
return null;
}
const cached = this.cache.get(
this.buildCacheKey(serverUrl, username, password)
this.buildCacheKey(
connection.serverUrl,
connection.username,
connection.password
)
);
if (!cached) {
return null;
@@ -140,51 +175,62 @@ export class PortalStatusService {
return `${serverUrl}|${username}|${password}`;
}
private normalizeConnection(
serverUrl: string,
username: string,
password: string
): {
password: string;
serverUrl: string;
username: string;
} | null {
try {
const normalizedUsername = username.trim();
const normalizedPassword = password.trim();
if (!normalizedUsername || !normalizedPassword) {
return null;
}
return {
serverUrl: normalizeXtreamServerUrl(serverUrl),
username: normalizedUsername,
password: normalizedPassword,
};
} catch {
return null;
}
}
private async fetchPortalStatus(
serverUrl: string,
username: string,
password: string
): Promise<PortalStatus> {
try {
let normalizedUrl = serverUrl;
if (serverUrl && !serverUrl.endsWith('/')) {
normalizedUrl = serverUrl;
}
const response =
await this.dataService.sendIpcEvent<XtreamPortalStatusResponse>(
'XTREAM_REQUEST',
{
url: normalizedUrl,
params: {
password,
username,
action: 'get_account_info',
},
suppressErrorLog: true,
}
);
const payload = response?.payload;
if (!payload?.user_info?.status) {
return 'unavailable';
}
if (payload.user_info.status === 'Active') {
if (!payload.user_info.exp_date) {
return 'active';
for (const action of XTREAM_STATUS_ACTIONS) {
try {
const response =
await this.dataService.sendIpcEvent<XtreamPortalStatusResponse>(
'XTREAM_REQUEST',
{
url: serverUrl,
params: {
...(action ? { action } : {}),
password,
username,
},
suppressErrorLog: true,
}
);
const status = resolveXtreamPortalStatus(response?.payload);
if (status !== 'unavailable') {
return status;
}
const expDate = new Date(
parseInt(payload.user_info.exp_date, 10) * 1000
);
return expDate < new Date() ? 'expired' : 'active';
} else {
return 'inactive';
} catch {
// Try the next Xtream account-info action variant.
}
} catch {
return 'unavailable';
}
return 'unavailable';
}
/**
+1
View File
@@ -37,6 +37,7 @@ export * from './lib/xtream-code-actions';
export * from './lib/xtream-item.interface';
export * from './lib/xtream-live-stream.interface';
export * from './lib/xtream-response.interface';
export * from './lib/xtream-portal.utils';
export * from './lib/xtream-restore-state.util';
export * from './lib/xtream-recently-added.utils';
export * from './lib/xtream-serie-details.interface';
@@ -0,0 +1,107 @@
import {
extractXtreamCredentialsFromUrl,
normalizeXtreamServerUrl,
resolveXtreamPortalStatus,
} from './xtream-portal.utils';
describe('xtream portal utilities', () => {
describe('normalizeXtreamServerUrl', () => {
it.each([
[' https://example.com/ ', 'https://example.com'],
['https://example.com/base/', 'https://example.com/base'],
[
'https://example.com/get.php?username=user&password=pass&type=m3u_plus',
'https://example.com',
],
[
'https://example.com/base/player_api.php?username=user&password=pass',
'https://example.com/base',
],
])('normalizes %s to %s', (input, expected) => {
expect(normalizeXtreamServerUrl(input)).toBe(expected);
});
it('rejects non-http portal URLs', () => {
expect(() =>
normalizeXtreamServerUrl('file://example.com/portal')
).toThrow('Only http and https Xtream URLs are supported');
});
});
describe('extractXtreamCredentialsFromUrl', () => {
it('extracts and trims credentials from a full Xtream playlist URL', () => {
expect(
extractXtreamCredentialsFromUrl(
'https://example.com/get.php?username=%20user%20&password=%20pass%20&type=m3u_plus'
)
).toEqual({
username: 'user',
password: 'pass',
});
});
it('returns null when the URL has no usable credentials', () => {
expect(
extractXtreamCredentialsFromUrl('https://example.com/get.php')
).toBeNull();
});
});
describe('resolveXtreamPortalStatus', () => {
const now = new Date('2026-06-14T00:00:00.000Z');
it('accepts active status case-insensitively and treats exp_date 0 as unlimited', () => {
expect(
resolveXtreamPortalStatus(
{
user_info: {
auth: 1,
status: 'active',
exp_date: '0',
},
},
now
)
).toBe('active');
});
it('uses auth=1 as active when status is missing', () => {
expect(
resolveXtreamPortalStatus(
{
user_info: {
auth: '1',
exp_date: String(now.getTime() / 1000 + 3600),
},
},
now
)
).toBe('active');
});
it('marks explicit auth failure as inactive', () => {
expect(
resolveXtreamPortalStatus({
user_info: {
auth: 0,
},
})
).toBe('inactive');
});
it('marks active accounts with past expiration as expired', () => {
expect(
resolveXtreamPortalStatus(
{
user_info: {
auth: 1,
status: 'Active',
exp_date: String(now.getTime() / 1000 - 3600),
},
},
now
)
).toBe('expired');
});
});
});
@@ -0,0 +1,131 @@
export type XtreamPortalStatusType =
| 'active'
| 'inactive'
| 'expired'
| 'unavailable';
export interface XtreamPortalStatusResponseLike {
user_info?: {
auth?: boolean | number | string | null;
exp_date?: number | string | null;
status?: string | null;
} | null;
}
export interface XtreamCredentialsFromUrl {
password: string;
username: string;
}
const XTREAM_API_ENDPOINT_PATTERN = /\/(?:get|player_api)\.php$/i;
export function normalizeXtreamServerUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error('Xtream URL is required');
}
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Only http and https Xtream URLs are supported');
}
if (url.username || url.password) {
throw new Error('URL credentials are not supported');
}
const pathWithoutTrailingSlash = url.pathname.replace(/\/+$/, '');
const basePath = pathWithoutTrailingSlash.replace(
XTREAM_API_ENDPOINT_PATTERN,
''
);
return `${url.origin}${basePath}`;
}
export function extractXtreamCredentialsFromUrl(
value: string
): XtreamCredentialsFromUrl | null {
let url: URL;
try {
url = new URL(value.trim());
} catch {
return null;
}
const username = url.searchParams.get('username')?.trim() ?? '';
const password = url.searchParams.get('password')?.trim() ?? '';
if (!username || !password) {
return null;
}
return { username, password };
}
export function resolveXtreamPortalStatus(
response: XtreamPortalStatusResponseLike | null | undefined,
now = new Date()
): XtreamPortalStatusType {
const userInfo = response?.user_info;
if (!userInfo) {
return 'unavailable';
}
const auth = normalizeAuthValue(userInfo.auth);
if (auth === false) {
return 'inactive';
}
const normalizedStatus = userInfo.status?.trim().toLowerCase() ?? '';
if (normalizedStatus === 'expired') {
return 'expired';
}
const isActive =
normalizedStatus === 'active' ||
(auth === true && normalizedStatus.length === 0);
if (!isActive) {
return normalizedStatus ? 'inactive' : 'unavailable';
}
const expirationTimestamp = parseXtreamExpiration(userInfo.exp_date);
if (
expirationTimestamp !== null &&
expirationTimestamp * 1000 < now.getTime()
) {
return 'expired';
}
return 'active';
}
function normalizeAuthValue(
value: boolean | number | string | null | undefined
): boolean | null {
if (value === true || value === 1 || value === '1') {
return true;
}
if (value === false || value === 0 || value === '0') {
return false;
}
return null;
}
function parseXtreamExpiration(
value: number | string | null | undefined
): number | null {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return parsed;
}