diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index f9f9fbb88..875406256 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -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); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index e8ef3d09e..22d58326f 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -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): 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 ) ); diff --git a/apps/web-backend/project.json b/apps/web-backend/project.json index 88fa476fb..a29059af2 100644 --- a/apps/web-backend/project.json +++ b/apps/web-backend/project.json @@ -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" } } }, diff --git a/apps/web-backend/src/app/web-backend-app.spec.ts b/apps/web-backend/src/app/web-backend-app.spec.ts index 13fdca6b3..e9ab7f2a8 100644 --- a/apps/web-backend/src/app/web-backend-app.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.spec.ts @@ -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' } }); diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index 8ae5d854f..32bd75abe 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -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; @@ -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 { + 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; diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md new file mode 100644 index 000000000..39605087b --- /dev/null +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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. diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts new file mode 100644 index 000000000..2fd775d9d --- /dev/null +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts @@ -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', + }), + }) + ); + }); +}); diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index ef3f7e670..77d1a8497 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -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(); - 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 { 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; + } + } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts index 47c48ed1a..cbfe7108c 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.spec.ts @@ -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; diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index c71fae20f..b809d1e31 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -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 { 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, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 831ead825..2c1a281d3 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -33,6 +33,7 @@ export interface XtreamPlaylistData { referrer?: string; origin?: string; serverTimezone?: string; + allowedOutputFormats?: string[]; } /** diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts index c70fa35ed..e49a07319 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts @@ -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); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index 23a9efb6c..ec5a7db01 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -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 { - 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 { - 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( + 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, options?: XtreamRequestOptions ): Promise { + const normalizedUrl = normalizeXtreamServerUrl(url); const serializedParams: Record = {}; 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); + } } diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index a0c214fb4..92db66b06 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -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() diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index 53e3d13c3..0ec6014db 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -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(); + private readonly catchupSchemeCache = new Map< + string, + XtreamCatchupScheme + >(); private readonly catchupSchemeRequests = new Map< string, Promise @@ -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 diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index 3b5a73166..f1ab104a7 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -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; @@ -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 => { 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 => { 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, diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index 7461af037..a2aee2ff1 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -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, { diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts index 199dd2e7d..dd12bdebc 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-player.feature.ts @@ -74,6 +74,7 @@ export function withPlayer() { } return { + allowedOutputFormats: playlist.allowedOutputFormats, serverUrl: playlist.serverUrl, username: playlist.username, password: playlist.password, diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts new file mode 100644 index 000000000..c15138a7b --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts @@ -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; + 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(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index 449f26902..e196c89d0 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -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(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) { diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts index 04d35f37a..9ee8d80c6 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.spec.ts @@ -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'); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts index e114d8531..1611fbf82 100644 --- a/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts +++ b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts @@ -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' diff --git a/libs/services/src/lib/portal-status.service.spec.ts b/libs/services/src/lib/portal-status.service.spec.ts index 378774a18..3f0f8f2a3 100644 --- a/libs/services/src/lib/portal-status.service.spec.ts +++ b/libs/services/src/lib/portal-status.service.spec.ts @@ -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'); + }); }); diff --git a/libs/services/src/lib/portal-status.service.ts b/libs/services/src/lib/portal-status.service.ts index 5ad5c5f97..f53a80e83 100644 --- a/libs/services/src/lib/portal-status.service.ts +++ b/libs/services/src/lib/portal-status.service.ts @@ -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 { - 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 { - try { - let normalizedUrl = serverUrl; - if (serverUrl && !serverUrl.endsWith('/')) { - normalizedUrl = serverUrl; - } - - const response = - await this.dataService.sendIpcEvent( - '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( + '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'; } /** diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 26b132d62..79d3c0086 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/xtream-portal.utils.spec.ts b/libs/shared/interfaces/src/lib/xtream-portal.utils.spec.ts new file mode 100644 index 000000000..cde4acd1b --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-portal.utils.spec.ts @@ -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'); + }); + }); +}); diff --git a/libs/shared/interfaces/src/lib/xtream-portal.utils.ts b/libs/shared/interfaces/src/lib/xtream-portal.utils.ts new file mode 100644 index 000000000..afb76e303 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-portal.utils.ts @@ -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; +}