mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(xtream): improve portal compatibility
This commit is contained in:
1 parent
bc4a5958dc
commit
254879f92b
26 files changed
+1212
-186
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
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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' }] });
|
||||
|
||||
@@ -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>;
|
||||
@@ -185,7 +186,7 @@ export function createWebBackendApp(
|
||||
// Provider URLs are validated by /provider-targets before they enter the registry.
|
||||
// codeql[js/request-forgery]
|
||||
const response = await httpClient.get(
|
||||
appendPathSegment(url, 'player_api.php'),
|
||||
buildXtreamPlayerApiUrl(url),
|
||||
{
|
||||
params: getProxyParams(req, ['targetId']),
|
||||
}
|
||||
@@ -394,6 +395,13 @@ function appendPathSegment(url: URL, segment: string): string {
|
||||
return nextUrl.href;
|
||||
}
|
||||
|
||||
function buildXtreamPlayerApiUrl(url: URL): string {
|
||||
return appendPathSegment(
|
||||
new URL(normalizeXtreamServerUrl(url.href)),
|
||||
'player_api.php'
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePlaylistParse(options: {
|
||||
readonly guid: () => string;
|
||||
readonly httpClient: WebBackendHttpClient;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
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('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',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+36
-24
@@ -13,7 +13,11 @@ 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';
|
||||
|
||||
@Component({
|
||||
@@ -66,7 +70,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()),
|
||||
@@ -90,11 +94,7 @@ export class XtreamCodeImportComponent {
|
||||
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();
|
||||
|
||||
try {
|
||||
// User-initiated connection test — bypass the shared cache so the
|
||||
@@ -102,9 +102,9 @@ export class XtreamCodeImportComponent {
|
||||
// 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 +137,16 @@ 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();
|
||||
this.store.dispatch(
|
||||
PlaylistActions.addPlaylist({
|
||||
playlist: {
|
||||
...this.form.value,
|
||||
serverUrl,
|
||||
password: connection.password,
|
||||
serverUrl: connection.serverUrl,
|
||||
username: connection.username,
|
||||
} as Playlist,
|
||||
})
|
||||
);
|
||||
@@ -160,17 +160,29 @@ 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;
|
||||
} {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
+72
@@ -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;
|
||||
|
||||
+24
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,31 @@ 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('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';
|
||||
@@ -49,7 +50,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 +68,12 @@ 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);
|
||||
const streamFormat = this.resolveLiveStreamFormat(
|
||||
credentials,
|
||||
format ?? this.settingsStore.streamFormat() ?? 'ts'
|
||||
);
|
||||
return `${normalizedCredentials.serverUrl}/live/${normalizedCredentials.username}/${normalizedCredentials.password}/${xtreamId}.${streamFormat}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +90,8 @@ export class XtreamUrlService {
|
||||
if (!streamId || !extension) {
|
||||
return '';
|
||||
}
|
||||
return `${credentials.serverUrl}/movie/${credentials.username}/${credentials.password}/${streamId}.${extension}`;
|
||||
const normalizedCredentials = this.normalizeCredentials(credentials);
|
||||
return `${normalizedCredentials.serverUrl}/movie/${normalizedCredentials.username}/${normalizedCredentials.password}/${streamId}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +102,8 @@ 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);
|
||||
return `${normalizedCredentials.serverUrl}/series/${normalizedCredentials.username}/${normalizedCredentials.password}/${episode.id}.${episode.container_extension}`;
|
||||
}
|
||||
|
||||
constructCatchupUrl(
|
||||
@@ -115,17 +124,20 @@ export class XtreamUrlService {
|
||||
);
|
||||
|
||||
if (scheme === 'legacy') {
|
||||
const normalizedCredentials =
|
||||
this.normalizeCredentials(credentials);
|
||||
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`;
|
||||
const normalizedCredentials = this.normalizeCredentials(credentials);
|
||||
return `${normalizedCredentials.serverUrl}/timeshift/${normalizedCredentials.username}/${normalizedCredentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`;
|
||||
}
|
||||
|
||||
async resolveCatchupUrl(
|
||||
@@ -264,6 +276,41 @@ export class XtreamUrlService {
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeCredentials(credentials: XtreamCredentials): {
|
||||
password: string;
|
||||
rawPassword: string;
|
||||
rawUsername: string;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
} {
|
||||
const rawUsername = credentials.username.trim();
|
||||
const rawPassword = credentials.password.trim();
|
||||
|
||||
return {
|
||||
password: encodeURIComponent(rawPassword),
|
||||
rawPassword,
|
||||
rawUsername,
|
||||
serverUrl: normalizeXtreamServerUrl(credentials.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,85 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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,17 +116,25 @@ 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;
|
||||
patchState(store, { portalStatus });
|
||||
if (serverTimezone) {
|
||||
if (serverTimezone || allowedOutputFormats?.length) {
|
||||
const current = store.currentPlaylist();
|
||||
if (current) {
|
||||
patchState(store, {
|
||||
currentPlaylist: {
|
||||
...current,
|
||||
serverTimezone,
|
||||
...(serverTimezone
|
||||
? { serverTimezone }
|
||||
: {}),
|
||||
...(allowedOutputFormats?.length
|
||||
? { allowedOutputFormats }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,103 @@ 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('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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -140,51 +162,65 @@ 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;
|
||||
}
|
||||
let fallbackStatus: PortalStatus = 'unavailable';
|
||||
|
||||
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';
|
||||
fallbackStatus = status;
|
||||
} catch {
|
||||
fallbackStatus = 'unavailable';
|
||||
}
|
||||
} catch {
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
return fallbackStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user