fix(xtream): address portal review feedback

This commit is contained in:
4gray committed 2026-06-14 13:47:42 +02:00
1 parent 254879f92b
commit 20d0f01428
12 files changed
+295 -51

No files matched your search

@@ -40,6 +40,24 @@ describe('XtreamCodeImportComponent', () => {
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'
@@ -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';
@@ -20,6 +22,22 @@ import {
} 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,
@@ -80,6 +98,7 @@ export class XtreamCodeImportComponent {
serverUrl: new FormControl('', [
Validators.required,
Validators.pattern(this.URL_REGEX),
xtreamServerUrlValidator,
]),
importDate: new FormControl(new Date().toISOString()),
});
@@ -93,9 +112,13 @@ export class XtreamCodeImportComponent {
async testConnection(): Promise<void> {
if (!this.form.valid) return;
this.isTestingConnection = true;
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
@@ -140,6 +163,10 @@ export class XtreamCodeImportComponent {
if (!this.form.valid) return;
const connection = this.getNormalizedConnection();
if (!connection) {
return;
}
this.store.dispatch(
PlaylistActions.addPlaylist({
playlist: {
@@ -176,13 +203,17 @@ export class XtreamCodeImportComponent {
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(),
};
} | 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;
}
}
}
@@ -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';
@@ -69,6 +73,41 @@ describe('XtreamUrlService', () => {
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()
@@ -33,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,
@@ -69,6 +77,10 @@ export class XtreamUrlService {
format?: string
): string {
const normalizedCredentials = this.normalizeCredentials(credentials);
if (!normalizedCredentials) {
return '';
}
const streamFormat = this.resolveLiveStreamFormat(
credentials,
format ?? this.settingsStore.streamFormat() ?? 'ts'
@@ -91,6 +103,10 @@ export class XtreamUrlService {
return '';
}
const normalizedCredentials = this.normalizeCredentials(credentials);
if (!normalizedCredentials) {
return '';
}
return `${normalizedCredentials.serverUrl}/movie/${normalizedCredentials.username}/${normalizedCredentials.password}/${streamId}.${extension}`;
}
@@ -103,6 +119,10 @@ export class XtreamUrlService {
episode: XtreamSerieEpisode
): string {
const normalizedCredentials = this.normalizeCredentials(credentials);
if (!normalizedCredentials) {
return '';
}
return `${normalizedCredentials.serverUrl}/series/${normalizedCredentials.username}/${normalizedCredentials.password}/${episode.id}.${episode.container_extension}`;
}
@@ -114,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)
@@ -124,8 +149,6 @@ export class XtreamUrlService {
);
if (scheme === 'legacy') {
const normalizedCredentials =
this.normalizeCredentials(credentials);
const params = new URLSearchParams({
username: normalizedCredentials.rawUsername,
password: normalizedCredentials.rawPassword,
@@ -136,7 +159,6 @@ export class XtreamUrlService {
return `${normalizedCredentials.serverUrl}/streaming/timeshift.php?${params.toString()}`;
}
const normalizedCredentials = this.normalizeCredentials(credentials);
return `${normalizedCredentials.serverUrl}/timeshift/${normalizedCredentials.username}/${normalizedCredentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`;
}
@@ -232,6 +254,10 @@ export class XtreamUrlService {
serverTimezone
);
if (!restUrl || !legacyUrl) {
return 'rest';
}
const restStatus = await this.probeCatchupUrl(restUrl);
let detectedScheme: XtreamCatchupScheme;
@@ -276,21 +302,27 @@ export class XtreamUrlService {
);
}
private normalizeCredentials(credentials: XtreamCredentials): {
password: string;
rawPassword: string;
rawUsername: string;
serverUrl: string;
username: string;
} {
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: normalizeXtreamServerUrl(credentials.serverUrl),
serverUrl,
username: encodeURIComponent(rawUsername),
};
}
@@ -82,4 +82,22 @@ describe('withPortal', () => {
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();
});
});
@@ -120,24 +120,24 @@ export function withPortal() {
resolveXtreamPortalStatus(response);
const serverTimezone =
response?.server_info?.timezone ?? undefined;
const allowedOutputFormats =
response?.user_info?.allowed_output_formats;
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 || allowedOutputFormats?.length) {
const current = store.currentPlaylist();
if (current) {
patchState(store, {
currentPlaylist: {
...current,
...(serverTimezone
? { serverTimezone }
: {}),
...(allowedOutputFormats?.length
? { allowedOutputFormats }
: {}),
},
});
}
const current = store.currentPlaylist();
if (current) {
patchState(store, {
currentPlaylist: {
...current,
allowedOutputFormats,
...(serverTimezone
? { serverTimezone }
: {}),
},
});
}
return portalStatus;
} catch (error) {
@@ -110,6 +110,32 @@ describe('PortalStatusService', () => {
);
});
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) => {
+16 -6
View File
@@ -137,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;
@@ -193,8 +206,6 @@ export class PortalStatusService {
username: string,
password: string
): Promise<PortalStatus> {
let fallbackStatus: PortalStatus = 'unavailable';
for (const action of XTREAM_STATUS_ACTIONS) {
try {
const response =
@@ -214,13 +225,12 @@ export class PortalStatusService {
if (status !== 'unavailable') {
return status;
}
fallbackStatus = status;
} catch {
fallbackStatus = 'unavailable';
// Try the next Xtream account-info action variant.
}
}
return fallbackStatus;
return 'unavailable';
}
/**