perf(portals): fast-fail requests to portal hosts that stopped answering (#1421)

This commit is contained in:
4gray authored and GitHub committed 2026-08-13 07:31:23 +02:00
1 parent 2d7811eb5f
commit e3f72f7dce
50 files changed
+3073 -174

No files matched your search

@@ -17,6 +17,7 @@ import {
} from '@iptvnator/services';
import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state';
import {
CONNECTIVITY_GUARD_RESET,
ELECTRON_BRIDGE_SECURITY_ERROR_CODES,
PLAYLIST_UPDATE,
Playlist,
@@ -688,4 +689,49 @@ describe('PlaylistRefreshActionService', () => {
})
);
});
it('clears the connectivity guard before deleting the cached Xtream catalog', async () => {
// This refresh deletes the catalog and then forces a route bootstrap
// whose status request an open guard would fast-fail — leaving the user
// with no catalog at all until the cooldown expires.
const item = {
_id: 'xtream-guard',
title: 'Guarded Xtream',
serverUrl: 'http://panel.example:8080',
username: 'user',
password: 'secret',
macAddress: undefined,
portalUrl: undefined,
} as unknown as PlaylistMeta;
let confirmPromise: Promise<void> | undefined;
const order: string[] = [];
dataService.sendIpcEvent.mockImplementation((event: string) => {
order.push(`ipc:${event}`);
return Promise.resolve({ success: true });
});
databaseService.deleteXtreamPlaylistContent.mockImplementation(() => {
order.push('deleteXtreamPlaylistContent');
return Promise.resolve({
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
sourcePins: [],
});
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
service.refresh(item);
await confirmPromise;
expect(order[0]).toBe(`ipc:${CONNECTIVITY_GUARD_RESET}`);
expect(order).toContain('deleteXtreamPlaylistContent');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
{ url: item.serverUrl }
);
});
});
@@ -11,6 +11,7 @@ import {
isDbAbortError,
PlaybackPositionService,
PlaylistRefreshService,
resetHostConnectivityGuard,
RuntimeCapabilitiesService,
SettingsStore,
XtreamPendingRestoreService,
@@ -124,6 +125,16 @@ export class PlaylistRefreshActionService {
});
try {
// Before anything destructive: this refresh deletes the
// cached catalog and then forces a route bootstrap whose
// status request would be fast-failed by an open
// connectivity guard, leaving the user with no catalog at
// all until the cooldown expires.
await resetHostConnectivityGuard(
this.dataService,
item.serverUrl
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
@@ -26,7 +26,11 @@ import {
SortOrder,
SortService,
} from '@iptvnator/services';
import { PLAYLIST_UPDATE, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
CONNECTIVITY_GUARD_RESET,
PLAYLIST_UPDATE,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
@@ -442,7 +446,11 @@ describe('RecentPlaylistsComponent busy state', () => {
);
component.refreshXtreamPlaylist(item);
await Promise.resolve();
// The refresh clears the connectivity guard before it starts deleting,
// so drain the microtask queue rather than counting exact ticks.
for (let index = 0; index < 6; index += 1) {
await Promise.resolve();
}
expect(component.isRefreshPending(item._id)).toBe(true);
expect(component.getBusyMessage(item)).toBe(
@@ -676,4 +684,47 @@ describe('RecentPlaylistsComponent busy state', () => {
})
);
});
it('clears the connectivity guard before deleting the cached Xtream catalog', async () => {
// Second, independent implementation of the destructive refresh (the
// Workspace sources page). Same consequence as the shared action: the
// catalog is already gone by the time an open guard fast-fails the
// re-import bootstrap.
const item = {
_id: 'xtream-guard',
title: 'Guarded Xtream',
serverUrl: 'http://panel.example:8080',
} as PlaylistMeta;
const order: string[] = [];
let confirmPromise: Promise<void> | undefined;
dataService.sendIpcEvent.mockImplementation((event: string) => {
order.push(`ipc:${event}`);
return Promise.resolve({ success: true });
});
databaseService.deleteXtreamPlaylistContent.mockImplementation(() => {
order.push('deleteXtreamPlaylistContent');
return Promise.resolve({
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
sourcePins: [],
});
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
component.refreshXtreamPlaylist(item);
await confirmPromise;
expect(order[0]).toBe(`ipc:${CONNECTIVITY_GUARD_RESET}`);
expect(order).toContain('deleteXtreamPlaylistContent');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
{ url: item.serverUrl }
);
});
});
@@ -36,6 +36,7 @@ import {
PlaybackPositionService,
PlaylistDeleteActionService,
PlaylistRefreshService,
resetHostConnectivityGuard,
RuntimeCapabilitiesService,
SortBy,
SortService,
@@ -366,6 +367,16 @@ export class RecentPlaylistsComponent {
this.databaseService.createOperationId('xtream-refresh');
try {
// Before anything destructive, and for the same reason as
// the shared refresh action: this deletes the cached catalog
// and then navigates to re-import it, and an open
// connectivity guard would fast-fail that bootstrap — the
// user would be left with no catalog at all.
await resetHostConnectivityGuard(
this.dataService,
item.serverUrl
);
// Show immediate feedback — deletion can take several seconds
// for large playlists.
this.snackBar.open(
@@ -1,6 +1,9 @@
import { TestBed } from '@angular/core/testing';
import { DataService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
CONNECTIVITY_GUARD_RESET,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { StalkerItvCacheService } from './stalker-itv-cache.service';
import { StalkerSessionService } from './stalker-session.service';
@@ -50,7 +53,9 @@ function pageOf(items: unknown[], totalItems: number, pageSize = 14) {
};
}
const UNSUPPORTED_ACTION = { js: { error: 'Unknown action: get_all_channels' } };
const UNSUPPORTED_ACTION = {
js: { error: 'Unknown action: get_all_channels' },
};
// Depth 10: executeStalkerRequest routes through an extra async hop for
// the portal-repair pipeline, so page transitions settle a tick later.
@@ -66,7 +71,12 @@ describe('StalkerItvCacheService', () => {
function mockRequests(handlers: RequestHandlers): void {
sendIpcEvent.mockImplementation(
async (_event: unknown, payload: unknown) => {
async (event: unknown, payload: unknown) => {
// An explicit refresh clears the main process' connectivity
// guard first; that call carries no `params`.
if (event === CONNECTIVITY_GUARD_RESET) {
return { success: true };
}
const params = (payload as { params: StalkerParams }).params;
if (params['action'] === 'get_all_channels') {
if (!handlers.allChannels) {
@@ -282,6 +292,22 @@ describe('StalkerItvCacheService', () => {
}
});
it('a refresh clears the connectivity guard before contacting the portal', async () => {
// Same reason it bypasses the local cooldown: the user asked for fresh
// channels, so a host the main process gave up on has to be contacted
// for real instead of fast-failed.
mockRequests({
allChannels: () => pageOf([channel('1', 'News One', '5')], 1),
});
await service.refresh(PLAYLIST);
expect(sendIpcEvent.mock.calls[0][0]).toBe(CONNECTIVITY_GUARD_RESET);
expect(sendIpcEvent.mock.calls[0][1]).toEqual({
url: PLAYLIST.portalUrl,
});
});
it('a refresh bypasses the error cooldown', async () => {
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(2_000_000);
try {
@@ -307,10 +333,7 @@ describe('StalkerItvCacheService', () => {
it('deduplicates channels returned across crawl pages (portal ignoring the page param)', async () => {
const samePage = () =>
pageOf(
[
channel('1', 'News One', '5'),
channel('2', 'Sports HD', '9'),
],
[channel('1', 'News One', '5'), channel('2', 'Sports HD', '9')],
// Portal claims many items but returns the same two regardless
// of `p`.
280
@@ -341,9 +364,10 @@ describe('StalkerItvCacheService', () => {
await service.ensureLoaded(PLAYLIST);
expect(
service.getChannels(PLAYLIST)?.map((c) => c.id)
).toEqual(['1', '2']);
expect(service.getChannels(PLAYLIST)?.map((c) => c.id)).toEqual([
'1',
'2',
]);
});
it('deduplicates concurrent load requests', async () => {
@@ -1,6 +1,9 @@
import { Injectable, WritableSignal, inject, signal } from '@angular/core';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from '@iptvnator/services';
import {
DataService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { StalkerItvChannel } from './models';
import {
@@ -132,6 +135,14 @@ export class StalkerItvCacheService {
return pending;
}
// Same reason this clears its own error cooldown: the user asked for
// fresh channels, so a host the main process gave up on must be
// contacted for real rather than fast-failed.
await resetHostConnectivityGuard(
this.requestDeps.dataService,
playlist.portalUrl
);
this.unsupportedKeys.delete(key);
this.errorCooldownUntil.delete(key);
await this.runLoad(key, playlist);
@@ -1,5 +1,9 @@
import { TestBed } from '@angular/core/testing';
import { DataService } from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
STALKER_REQUEST,
} from '@iptvnator/shared/interfaces';
import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service';
import { StalkerSessionService } from './stalker-session.service';
@@ -23,7 +27,13 @@ describe('StalkerPortalDiscoveryService', () => {
function mockProbes(
handlers: Record<string, { resolve?: unknown; reject?: unknown }>
): void {
sendIpcEvent.mockImplementation((_event, payload) => {
sendIpcEvent.mockImplementation((event, payload) => {
// Discovery clears the main process' connectivity guard first; the
// real handler answers with the outcome of that reset.
if (event === CONNECTIVITY_GUARD_RESET) {
return Promise.resolve({ success: true });
}
const { url } = payload as { url: string };
const handler = handlers[url];
if (!handler) {
@@ -36,6 +46,13 @@ describe('StalkerPortalDiscoveryService', () => {
});
}
/** The portal requests only, excluding the guard reset discovery sends. */
function probeCalls(): unknown[][] {
return sendIpcEvent.mock.calls.filter(
([event]) => event === STALKER_REQUEST
);
}
beforeEach(() => {
sendIpcEvent = jest.fn();
authenticate = jest.fn();
@@ -66,7 +83,64 @@ describe('StalkerPortalDiscoveryService', () => {
});
expect(authenticate).not.toHaveBeenCalled();
// The winning candidate ends discovery — no further probes.
expect(sendIpcEvent).toHaveBeenCalledTimes(1);
expect(probeCalls()).toHaveLength(1);
});
describe('host connectivity guard', () => {
it('clears the guard before probing, so an edited address is never refused', async () => {
// Import, an edited connection and lazy repair all come through
// here, and each is a deliberate "talk to this portal".
mockProbes({
'http://panel.example/portal.php': {
resolve: { js: [{ id: '1', title: 'News' }] },
},
});
await service.discover('http://panel.example/c', MAC);
expect(sendIpcEvent.mock.calls[0]).toEqual([
CONNECTIVITY_GUARD_RESET,
{ url: 'http://panel.example/c' },
]);
});
it('exempts every probe from the guard', async () => {
// Candidates that do not exist fail BY DESIGN; counting those would
// let the guard declare a slow-but-alive host unreachable.
mockProbes({
'http://ministra.example/portal.php': {
reject: { message: 'HTTP Error: Not Found', status: 404 },
},
'http://ministra.example/server/load.php': {
resolve: { js: [{ id: '1', title: 'News' }] },
},
});
await service.discover('http://ministra.example/c', MAC);
expect(probeCalls()).toHaveLength(2);
for (const [, payload] of probeCalls()) {
expect(payload).toMatchObject({ skipConnectionGuard: true });
}
});
it('probes anyway when clearing the guard fails', async () => {
mockProbes({
'http://panel.example/portal.php': {
resolve: { js: [{ id: '1', title: 'News' }] },
},
});
sendIpcEvent.mockImplementationOnce(() =>
Promise.reject(new Error('IPC unavailable'))
);
const outcome = await service.discover(
'http://panel.example/c',
MAC
);
expect(outcome).toMatchObject({ status: 'resolved' });
});
});
it('falls through a 404 portal.php to server/load.php and classifies by handshake', async () => {
@@ -126,7 +200,7 @@ describe('StalkerPortalDiscoveryService', () => {
portalUrl: 'http://ministra.example/server/load.php',
isFullStalkerPortal: true,
});
expect(sendIpcEvent).toHaveBeenCalledTimes(1);
expect(probeCalls()).toHaveLength(1);
});
it('classifies a token-enforcing portal.php panel as a full portal', async () => {
@@ -287,7 +361,7 @@ describe('StalkerPortalDiscoveryService', () => {
const outcome = await service.discover('http://down.example/c', MAC);
expect(outcome).toEqual({ status: 'unreachable' });
expect(sendIpcEvent).toHaveBeenCalledTimes(1);
expect(probeCalls()).toHaveLength(1);
});
it('keeps probing past a candidate timeout — one handler can hang while siblings work', async () => {
@@ -374,13 +448,16 @@ describe('StalkerPortalDiscoveryService', () => {
);
const discovery = service.discover('http://slow.example/c', MAC);
// Let the probe resolve so the auth attempt actually starts.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
// Let the guard reset and the probe resolve so the auth attempt
// actually starts.
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
}
jest.advanceTimersByTime(65_000);
await Promise.resolve();
for (let i = 0; i < 6; i += 1) {
await Promise.resolve();
}
// The abandoned attempt is cancelled, so its get_profile never
// goes out to adopt the MAC's token behind a later candidate.
@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { DataService } from '@iptvnator/services';
import { DataService, resetHostConnectivityGuard } from '@iptvnator/services';
import { STALKER_REQUEST } from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
@@ -153,6 +153,11 @@ export class StalkerPortalDiscoveryService {
const candidates = buildStalkerEndpointCandidates(rawUrl);
let authRejection: StalkerPortalDiscoveryRejection | null = null;
// Discovery runs on import, on an edited connection and on lazy
// repair — every one of them is a deliberate "talk to this portal"
// and must not inherit a refusal recorded for the previous address.
await resetHostConnectivityGuard(this.dataService, rawUrl);
for (const candidate of candidates) {
let probeResponse: unknown;
try {
@@ -352,6 +357,11 @@ export class StalkerPortalDiscoveryService {
// Probing absent endpoints fails BY DESIGN — the
// transport services skip their error snackbar for us.
silent: true,
// For the same reason these failures must not feed the
// main process' connectivity guard: several candidates on
// one host are expected to fail, and treating that as "the
// host is dead" would abandon a slow-but-alive portal.
skipConnectionGuard: true,
})
),
PROBE_TIMEOUT_MS
@@ -1,3 +1,4 @@
import { buildHostConnectivityFastFailMessage } from '@iptvnator/shared/interfaces';
import {
buildStalkerEndpointCandidates,
classifyStalkerProbeResponse,
@@ -5,6 +6,7 @@ import {
isStalkerAuthFailureBody,
isStalkerAuthFailureMessage,
isStalkerAuthFailureResponse,
isStalkerProbeTimeout,
legacyTransformStalkerPortalUrl,
normalizeStalkerPortalInputUrl,
} from './stalker-portal-discovery.utils';
@@ -367,6 +369,65 @@ describe('getStalkerRequestErrorStatus', () => {
});
});
describe('host connectivity guard fast-fail message', () => {
// The main process refuses requests to a host that stopped answering, and
// only the message survives `ipcRenderer.invoke`. These assertions are the
// contract: the wording must land in the "connection-level failure" slot,
// because that is what a tripped guard actually means. Any other reading
// has a consequence — a parsed status makes discovery walk every candidate
// and can fire lazy repair against a host we just declared dead; timeout
// wording loses the abort-early semantics; an auth phrase fires repair too.
const message = buildHostConnectivityFastFailMessage(
'http://portal.example:8080'
);
const ipcWrapped = new Error(
`Error invoking remote method 'STALKER_REQUEST': ${message}`
);
it('carries no HTTP status, bare or IPC-wrapped', () => {
expect(
getStalkerRequestErrorStatus(new Error(message))
).toBeUndefined();
expect(getStalkerRequestErrorStatus(ipcWrapped)).toBeUndefined();
});
it('does not read as a timeout', () => {
expect(isStalkerProbeTimeout(new Error(message))).toBe(false);
expect(isStalkerProbeTimeout(ipcWrapped)).toBe(false);
});
it('does not read as an authorization failure', () => {
expect(isStalkerAuthFailureMessage(message)).toBe(false);
expect(isStalkerAuthFailureMessage(ipcWrapped.message)).toBe(false);
});
it('survives an endpoint whose hostname reads like a classifier keyword', () => {
// The endpoint is interpolated user data: a portal at
// `https://authorization.example` would otherwise match the broad auth
// phrase set and send an unreachable host into lazy portal repair.
const keywordHost = buildHostConnectivityFastFailMessage(
'https://authorization.example'
);
expect(isStalkerAuthFailureMessage(keywordHost)).toBe(false);
expect(isStalkerProbeTimeout(new Error(keywordHost))).toBe(false);
expect(
getStalkerRequestErrorStatus(new Error(keywordHost))
).toBeUndefined();
const unauthorizedHost = buildHostConnectivityFastFailMessage(
'http://unauthorized.example:8080'
);
expect(isStalkerAuthFailureMessage(unauthorizedHost)).toBe(false);
});
it('names the endpoint so the snackbar it reaches says something useful', () => {
// Scheme included: the same panel can be imported over both HTTP and
// HTTPS, and the user needs to know which one was skipped.
expect(message).toContain('http://portal.example:8080');
});
});
describe('legacyTransformStalkerPortalUrl', () => {
it('uses portal.php for an offline bare host', () => {
expect(legacyTransformStalkerPortalUrl('http://x.example')).toBe(
@@ -9,7 +9,10 @@
* it responds, instead of guessing from the URL shape (#850, #686, #755).
*/
import { isStalkerAuthFailureResponse } from '@iptvnator/shared/interfaces';
import {
isHostConnectivityFastFailMessage,
isStalkerAuthFailureResponse,
} from '@iptvnator/shared/interfaces';
/**
* Candidate API endpoints for a pasted portal URL, in probe order.
@@ -199,6 +202,13 @@ export function isStalkerProbeTimeout(error: unknown): boolean {
}
const message = String((error as { message?: unknown }).message ?? '');
// The connectivity guard's refusal names the portal endpoint, and a
// hostname is user data. A guard refusal is a connection-level failure, so
// it must not be read as one candidate hanging while its siblings answer.
if (isHostConnectivityFastFailMessage(message)) {
return false;
}
return /timed out|timeout of \d+\s*ms|ETIMEDOUT/i.test(message);
}
@@ -2,7 +2,10 @@ import { TestBed } from '@angular/core/testing';
import { of, Subject, throwError } from 'rxjs';
import type { Playlist } from '@iptvnator/shared/interfaces';
import { PlaylistsService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
PlaylistMeta,
buildHostConnectivityFastFailMessage,
} from '@iptvnator/shared/interfaces';
import { StalkerPortalDiscoveryService } from './stalker-portal-discovery.service';
import { StalkerPortalRepairService } from './stalker-portal-repair.service';
import { StalkerSessionService } from './stalker-session.service';
@@ -228,6 +231,22 @@ describe('StalkerPortalRepairService', () => {
);
});
it('never triggers on the connectivity guard refusing a dead host', () => {
// Repair means "re-probe every candidate endpoint". Doing that
// because the host stopped answering would spend a full discovery
// run on a host we already know is not there.
expect(
service.shouldAttemptRepair(
MISCLASSIFIED,
new Error(
buildHostConnectivityFastFailMessage(
'http://portal.example:8080'
)
)
)
).toBe(false);
});
it('never triggers for playlists without portal coordinates', () => {
expect(
service.shouldAttemptRepair(
@@ -3,7 +3,11 @@ import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces';
import {
CONNECTIVITY_GUARD_RESET,
PlaylistMeta,
StalkerPortalActions,
} from '@iptvnator/shared/interfaces';
import { StalkerItvChannel } from '../../models';
import { StalkerItvCacheService } from '../../stalker-itv-cache.service';
import { StalkerSessionService } from '../../stalker-session.service';
@@ -107,7 +111,9 @@ async function waitForCondition(
* Controllable stand-in for StalkerItvCacheService so the legacy paged flow
* can be tested in isolation and the cache-served flow deterministically.
*/
function createItvCacheMock(initialChannels: StalkerItvChannel[] | null = null) {
function createItvCacheMock(
initialChannels: StalkerItvChannel[] | null = null
) {
const version = signal(0);
let channels = initialChannels;
@@ -395,9 +401,11 @@ describe('withStalkerContent failure states', () => {
store.setPage(1);
await waitForCondition(() => store.getPaginatedContent().length === 3);
expect(
store.getPaginatedContent().map((item) => item.name)
).toEqual(['Movie page 1', 'Shared Movie', 'Movie page 2']);
expect(store.getPaginatedContent().map((item) => item.name)).toEqual([
'Movie page 1',
'Shared Movie',
'Movie page 2',
]);
expect(store.hasMoreContent()).toBe(false);
});
@@ -492,19 +500,27 @@ describe('withStalkerContent failure states', () => {
);
// The failed append left page 1 on screen, not the empty state.
expect(
store.getPaginatedContent().map((item) => item.name)
).toEqual(['Movie page 1']);
expect(store.getPaginatedContent().map((item) => item.name)).toEqual([
'Movie page 1',
]);
expect(store.contentError()).toBeNull();
failPageTwo = false;
store.retryContentPage();
dataService.sendIpcEvent.mockClear();
await store.retryContentPage();
await waitForCondition(() => store.getPaginatedContent().length === 2);
// Two failed appends are exactly what opens the main process'
// connectivity guard, so the retry has to clear it FIRST — otherwise
// this button fast-fails without a request and looks broken.
const [firstChannel] = dataService.sendIpcEvent.mock.calls[0];
expect(firstChannel).toBe(CONNECTIVITY_GUARD_RESET);
expect(store.hasContentAppendError()).toBe(false);
expect(
store.getPaginatedContent().map((item) => item.name)
).toEqual(['Movie page 1', 'Movie page 2']);
expect(store.getPaginatedContent().map((item) => item.name)).toEqual([
'Movie page 1',
'Movie page 2',
]);
});
it('falls back to a synthetic all-radio category when radio categories are unavailable', async () => {
@@ -650,7 +666,12 @@ describe('withStalkerContent failure states', () => {
describe('withStalkerContent full ITV channel list cache', () => {
const CACHED_CHANNELS: StalkerItvChannel[] = [
{ id: '1', cmd: 'ffrt http://x/1', name: 'News One', tv_genre_id: '5' },
{ id: '2', cmd: 'ffrt http://x/2', name: 'Sports HD', tv_genre_id: '9' },
{
id: '2',
cmd: 'ffrt http://x/2',
name: 'Sports HD',
tv_genre_id: '9',
},
{ id: '3', cmd: 'ffrt http://x/3', name: 'News Two', tv_genre_id: '5' },
];
@@ -9,7 +9,7 @@ import {
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService } from '@iptvnator/services';
import { DataService, resetHostConnectivityGuard } from '@iptvnator/services';
import {
StalkerCategoryItem,
StalkerContentItem,
@@ -197,9 +197,7 @@ function buildEmptyContentPatch(
* Portals can shift items between pages while the list is being appended —
* a duplicate id would render the same card twice and break `track` hints.
*/
function dedupeContentById(
items: StalkerContentItem[]
): StalkerContentItem[] {
function dedupeContentById(items: StalkerContentItem[]): StalkerContentItem[] {
const seenIds = new Set<string>();
return items.filter((item) => {
const id =
@@ -848,50 +846,62 @@ export function withStalkerContent() {
const storeContext = store as typeof store &
StalkerContentResourceStoreContract;
const itvCache = inject(StalkerItvCacheService);
const dataService = inject(DataService);
return {
/**
* Kicks off the full ITV channel list load as soon as the Live TV
* section is entered (instead of waiting for the first category
* click), so the all-channels view and category count badges are
* available immediately. Safe to call repeatedly — the cache
* de-duplicates in-flight loads and memoizes unsupported portals.
*/
preloadItvChannels(): void {
void itvCache.ensureLoaded(storeContext.currentPlaylist());
},
/**
* Re-runs the content loader with unchanged params — the retry
* for a failed append page.
*/
retryContentPage(): void {
patchState(store, { appendError: null });
storeContext.getContentResource.reload();
},
async refreshItvChannels(): Promise<void> {
await itvCache.refresh(storeContext.currentPlaylist());
},
setCategories(
type: StalkerContentType,
categories: StalkerCategoryItem[]
) {
patchState(store, buildCategoryPatch(type, categories));
},
resetCategories() {
patchState(store, {
vodCategories: [],
seriesCategories: [],
itvCategories: [],
radioCategories: [],
categoryError: null,
});
},
setItvChannels(channels: StalkerItvChannel[]) {
patchState(store, { itvChannels: channels });
},
setRadioChannels(channels: StalkerItvChannel[]) {
patchState(store, { radioChannels: channels });
},
/**
* Kicks off the full ITV channel list load as soon as the Live TV
* section is entered (instead of waiting for the first category
* click), so the all-channels view and category count badges are
* available immediately. Safe to call repeatedly — the cache
* de-duplicates in-flight loads and memoizes unsupported portals.
*/
preloadItvChannels(): void {
void itvCache.ensureLoaded(storeContext.currentPlaylist());
},
/**
* Re-runs the content loader with unchanged params — the retry
* for a failed append page.
*
* The guard reset comes FIRST: two failed appends are exactly what
* opens the breaker, so without it this button would fast-fail
* without a request and appear to do nothing for 30 seconds.
*/
async retryContentPage(): Promise<void> {
// Clear the flag synchronously, before the await: otherwise
// this stays re-enterable and the next `nearEnd` event
// fires a second retry.
patchState(store, { appendError: null });
await resetHostConnectivityGuard(
dataService,
storeContext.currentPlaylist()?.portalUrl
);
storeContext.getContentResource.reload();
},
async refreshItvChannels(): Promise<void> {
await itvCache.refresh(storeContext.currentPlaylist());
},
setCategories(
type: StalkerContentType,
categories: StalkerCategoryItem[]
) {
patchState(store, buildCategoryPatch(type, categories));
},
resetCategories() {
patchState(store, {
vodCategories: [],
seriesCategories: [],
itvCategories: [],
radioCategories: [],
categoryError: null,
});
},
setItvChannels(channels: StalkerItvChannel[]) {
patchState(store, { itvChannels: channels });
},
setRadioChannels(channels: StalkerItvChannel[]) {
patchState(store, { radioChannels: channels });
},
};
})
);
@@ -4,14 +4,18 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { of, throwError } from 'rxjs';
import { StalkerAccountInfoService } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DataService, PlaylistsService } from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { StalkerAccountInfoComponent } from './stalker-account-info.component';
describe('StalkerAccountInfoComponent', () => {
let fixture: ComponentFixture<StalkerAccountInfoComponent>;
let component: StalkerAccountInfoComponent;
let accountInfoService: { fetchAccountInfo: jest.Mock };
let dataService: { sendIpcEvent: jest.Mock };
let playlistsService: { getPlaylist: jest.Mock };
const playlist = {
@@ -48,6 +52,7 @@ describe('StalkerAccountInfoComponent', () => {
TranslateModule.forRoot(),
],
providers: [
{ provide: DataService, useValue: dataService },
{ provide: MAT_DIALOG_DATA, useValue: { playlist } },
{
provide: StalkerAccountInfoService,
@@ -71,6 +76,9 @@ describe('StalkerAccountInfoComponent', () => {
accountInfoService = {
fetchAccountInfo: jest.fn().mockResolvedValue(freshSnapshot),
};
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ success: true }),
};
playlistsService = {
getPlaylist: jest.fn().mockReturnValue(of(null)),
};
@@ -207,6 +215,10 @@ describe('StalkerAccountInfoComponent', () => {
TranslateModule.forRoot(),
],
providers: [
{
provide: DataService,
useValue: { sendIpcEvent: jest.fn() },
},
{
provide: MAT_DIALOG_DATA,
useValue: {
@@ -256,4 +268,25 @@ describe('StalkerAccountInfoComponent', () => {
.find((row) => row.labelKey === 'STALKER.ACCOUNT_INFO.STATUS')
).toBeUndefined();
});
it('clears the connectivity guard before the Retry button re-reads the profile', async () => {
// The profile request is exactly what a tripped guard fast-fails, so a
// reset placed after it would leave Retry doing nothing for 30 seconds.
// Opening the dialog deliberately does not reset.
await createComponent();
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
accountInfoService.fetchAccountInfo.mockClear();
await component.reload();
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
{ url: playlist.portalUrl }
);
expect(
dataService.sendIpcEvent.mock.invocationCallOrder[0]
).toBeLessThan(
accountInfoService.fetchAccountInfo.mock.invocationCallOrder[0]
);
});
});
@@ -17,7 +17,11 @@ import {
StalkerAccountSnapshot,
} from '@iptvnator/portal/stalker/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
import { PlaylistsService } from '@iptvnator/services';
import {
DataService,
PlaylistsService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import {
PlaylistMeta,
type StalkerAccountInfoDialogData,
@@ -56,6 +60,7 @@ export class StalkerAccountInfoComponent {
MAT_DIALOG_DATA
);
private readonly accountInfoService = inject(StalkerAccountInfoService);
private readonly dataService = inject(DataService);
private readonly playlistsService = inject(PlaylistsService);
private readonly logger = createLogger('StalkerAccountInfo');
@@ -222,7 +227,17 @@ export class StalkerAccountInfoComponent {
void this.load();
}
/**
* The template's Retry button. Clears the connectivity guard first, for the
* same reason as every other explicit retry: the profile request is what a
* tripped guard fast-fails. Opening the dialog does not reset — only a user
* action means "contact this host now".
*/
async reload(): Promise<void> {
await resetHostConnectivityGuard(
this.dataService,
this.playlist?.portalUrl
);
await this.load();
}
@@ -205,7 +205,7 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade<
}
retryAppend(): void {
this.stalkerStore.retryContentPage();
void this.stalkerStore.retryContentPage();
}
saveScrollPosition(scrollTop: number): void {
@@ -19,6 +19,7 @@ import {
} from '@iptvnator/portal/stalker/data-access';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { DataService, PlaylistsService } from '@iptvnator/services';
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { StalkerSearchComponent } from './stalker-search.component';
@@ -43,6 +44,12 @@ class StubStalkerInlineDetailComponent {
readonly inlinePlaybackClosed = output<void>();
}
async function flushMicrotasks(): Promise<void> {
for (let index = 0; index < 4; index += 1) {
await Promise.resolve();
}
}
describe('StalkerSearchComponent playback session key', () => {
let fixture: ComponentFixture<StalkerSearchComponent>;
const playlist = signal({
@@ -274,6 +281,7 @@ describe('StalkerSearchComponent playback session key', () => {
describe('StalkerSearchComponent result paging', () => {
let component: StalkerSearchComponent;
let dataService: { sendIpcEvent: jest.Mock };
const activePlaylist = signal({
_id: 'playlist|one',
title: 'Search portal',
@@ -289,6 +297,9 @@ describe('StalkerSearchComponent result paging', () => {
}
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ success: true }),
};
activePlaylist.set({
_id: 'playlist|one',
title: 'Search portal',
@@ -309,7 +320,7 @@ describe('StalkerSearchComponent result paging', () => {
},
},
{ provide: Location, useValue: { back: jest.fn() } },
{ provide: DataService, useValue: {} },
{ provide: DataService, useValue: dataService },
{
provide: PlaylistContextFacade,
useValue: { activePlaylist },
@@ -406,7 +417,7 @@ describe('StalkerSearchComponent result paging', () => {
expect(component.searchHasMore()).toBe(false);
});
it('keeps accumulated pages on a failed append and retries the SAME page', () => {
it('keeps accumulated pages on a failed append and retries the SAME page', async () => {
component.applySearchPageSuccess(1, searchItems('page1', 3), 6);
expect(component.searchHasMore()).toBe(true);
@@ -429,8 +440,19 @@ describe('StalkerSearchComponent result paging', () => {
const pageBefore = component.searchPage();
component.loadMoreSearchResults();
expect(component.searchPage()).toBe(pageBefore);
// Cleared synchronously, so a second near-end cannot re-enter the retry
// while the connectivity-guard reset is still in flight.
expect(component.searchAppendError()).toBe(false);
// The reload itself waits for that reset: a tripped guard would
// otherwise fast-fail this retry without contacting the portal.
expect(reload).not.toHaveBeenCalled();
await flushMicrotasks();
expect(reload).toHaveBeenCalledTimes(1);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
{ url: activePlaylist().portalUrl }
);
// With the error cleared, the following near-end advances normally.
component.loadMoreSearchResults();
@@ -514,11 +536,7 @@ describe('StalkerSearchComponent result paging', () => {
configurable: true,
value: { isLoading: () => false, reload: jest.fn(() => true) },
});
component.applySearchPageSuccess(
1,
searchItems('portalA', 3),
6
);
component.applySearchPageSuccess(1, searchItems('portalA', 3), 6);
component.loadMoreSearchResults();
expect(component.searchPage()).toBe(2);
@@ -22,7 +22,11 @@ import {
StalkerPortalRepairService,
StalkerSessionService,
} from '@iptvnator/portal/stalker/data-access';
import { DataService, PlaylistsService } from '@iptvnator/services';
import {
DataService,
PlaylistsService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
@@ -404,14 +408,30 @@ export class StalkerSearchComponent {
if (this.searchAppendError()) {
// Retry the SAME page — advancing would permanently omit it.
this.searchAppendError.set(false);
this.searchResultsResource.reload();
void this.retrySearchPage();
return;
}
this.searchPage.update((page) => page + 1);
}
/**
* Two failed search pages are exactly what opens the main process'
* connectivity guard, so the reset has to precede the reload — otherwise
* this retry fast-fails without contacting a portal that may have
* recovered, and keeps repeating the same error until the window expires.
*/
private async retrySearchPage(): Promise<void> {
// Clear the flag synchronously: awaiting first would leave this branch
// re-enterable, and the next `nearEnd` event would fire a second retry.
this.searchAppendError.set(false);
await resetHostConnectivityGuard(
this.dataService,
this.currentPlaylist()?.portalUrl
);
this.searchResultsResource.reload();
}
readonly isSelectedVodFavorite = signal<boolean>(false);
constructor() {
@@ -0,0 +1,125 @@
import { TestBed } from '@angular/core/testing';
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import { PortalStatusType } from '../../xtream-state';
import {
createContentTestProviders,
createContentTestStore,
createPendingRestoreServiceMock,
TEST_PLAYLIST,
} from './with-content.feature.spec-helpers';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
let checkPortalStatusMock: jest.Mock<Promise<PortalStatusType>, []>;
const TestContentStore = createContentTestStore(() => checkPortalStatusMock());
describe('withContent host connectivity guard', () => {
let store: InstanceType<typeof TestContentStore>;
let dataService: { sendIpcEvent: jest.Mock };
beforeEach(() => {
localStorage.clear();
checkPortalStatusMock = jest.fn().mockResolvedValue('active');
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ success: true }),
};
TestBed.configureTestingModule({
providers: createContentTestProviders(TestContentStore, {
dataSource: {
getCategories: jest.fn().mockResolvedValue([]),
getCachedCategories: jest.fn().mockResolvedValue([]),
getContent: jest.fn().mockResolvedValue([]),
getCachedContent: jest.fn().mockResolvedValue([]),
hasCategories: jest.fn().mockResolvedValue(true),
hasContent: jest.fn().mockResolvedValue(false),
restoreUserData: jest.fn().mockResolvedValue(undefined),
},
databaseService: {
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest
.fn()
.mockImplementation(
(prefix?: string) => `${prefix ?? 'db-op'}-1`
),
getXtreamImportStatus: jest
.fn()
.mockResolvedValue('completed'),
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
supportsDbOperationCancellation: jest
.fn()
.mockReturnValue(true),
},
xtreamApiService: {
cancelSession: jest.fn().mockResolvedValue(true),
},
pendingRestoreService: createPendingRestoreServiceMock(),
dataService,
}),
});
store = TestBed.inject(TestContentStore);
});
afterEach(() => {
localStorage.clear();
});
it('clears the guard before the portal status check the retry depends on', async () => {
// Ordering is the whole point. A tripped guard fast-fails that status
// check, which resolves to 'unavailable' and returns early — so a reset
// placed after it would never run, and the Retry button would silently
// do nothing until the guard's window expired on its own.
const order: string[] = [];
dataService.sendIpcEvent.mockImplementation((event: string) => {
order.push(`ipc:${event}`);
return Promise.resolve({ success: true });
});
checkPortalStatusMock.mockImplementation(() => {
order.push('checkPortalStatus');
return Promise.resolve('unavailable' as PortalStatusType);
});
await store.retryContentInitialization();
expect(order).toEqual([
`ipc:${CONNECTIVITY_GUARD_RESET}`,
'checkPortalStatus',
]);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
{ url: TEST_PLAYLIST.serverUrl }
);
});
it('retries anyway when clearing the guard fails', async () => {
dataService.sendIpcEvent.mockRejectedValue(
new Error('IPC unavailable')
);
await store.retryContentInitialization();
expect(checkPortalStatusMock).toHaveBeenCalled();
expect(store.isContentInitialized()).toBe(true);
});
it('leaves the guard alone for an ordinary initialization', async () => {
// Only a user-driven retry means "contact this host now"; the automatic
// first load must not clear evidence the guard just collected.
await store.initializeContent();
expect(dataService.sendIpcEvent).not.toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
expect.anything()
);
});
});
@@ -1,13 +1,8 @@
import { TestBed } from '@angular/core/testing';
import {
DatabaseService,
XtreamPendingRestoreService,
} from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from '../../data-sources/xtream-data-source.interface';
import { XtreamApiService } from '../../services/xtream-api.service';
import { PortalStatusType } from '../../xtream-state';
import {
createAbortError,
createContentTestProviders,
createContentTestStore,
createDeferred,
createPendingRestoreServiceMock,
@@ -87,25 +82,15 @@ describe('withContent loading-cached phase', () => {
checkPortalStatusMock = jest.fn().mockResolvedValue('active');
TestBed.configureTestingModule({
providers: [
TestContentStore,
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
providers: createContentTestProviders(TestContentStore, {
dataSource,
databaseService,
xtreamApiService: {
cancelSession: jest.fn().mockResolvedValue(true),
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: XtreamApiService,
useValue: { cancelSession: jest.fn().mockResolvedValue(true) },
},
{
provide: XtreamPendingRestoreService,
useValue: createPendingRestoreServiceMock(),
},
],
pendingRestoreService: createPendingRestoreServiceMock(),
dataService: { sendIpcEvent: jest.fn() },
}),
});
store = TestBed.inject(TestContentStore);
@@ -4,8 +4,17 @@ import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
} from '@iptvnator/shared/logging';
import { XtreamPendingRestoreSnapshot } from '@iptvnator/services';
import { XtreamPlaylistData } from '../../data-sources/xtream-data-source.interface';
import {
DataService,
DatabaseService,
XtreamPendingRestoreService,
XtreamPendingRestoreSnapshot,
} from '@iptvnator/services';
import {
XTREAM_DATA_SOURCE,
XtreamPlaylistData,
} from '../../data-sources/xtream-data-source.interface';
import { XtreamApiService } from '../../services/xtream-api.service';
import { PortalStatusType } from '../../xtream-state';
import { withContent } from './with-content.feature';
@@ -48,10 +57,7 @@ export function createPendingRestoreServiceMock() {
}
const serializedState = JSON.stringify(state);
if (
!activeSnapshot ||
activeSerializedState !== serializedState
) {
if (!activeSnapshot || activeSerializedState !== serializedState) {
activeSnapshot = {
playlistId,
revision: ++nextRevision,
@@ -68,16 +74,13 @@ export function createPendingRestoreServiceMock() {
expectedSnapshot: XtreamPendingRestoreSnapshot,
apply: (state: XtreamPendingRestoreState) => Promise<void>
) => {
const currentSnapshot =
mock.getSnapshotOrThrow(playlistId);
const currentSnapshot = mock.getSnapshotOrThrow(playlistId);
if (!currentSnapshot) {
return lastConsumedRevision === expectedSnapshot.revision
? 'consumed'
: 'superseded';
}
if (
currentSnapshot.revision !== expectedSnapshot.revision
) {
if (currentSnapshot.revision !== expectedSnapshot.revision) {
return 'superseded';
}
@@ -116,6 +119,34 @@ export function createContentTestStore(
);
}
/**
* The provider list every `withContent` spec needs. Shared so the dependency
* set lives in one place: the feature injects all five, and a spec that omits
* one fails with a NullInjector error rather than a useful message.
*/
export function createContentTestProviders(
store: unknown,
mocks: {
dataSource: unknown;
databaseService: unknown;
xtreamApiService: unknown;
pendingRestoreService: unknown;
dataService: unknown;
}
) {
return [
store,
{ provide: XTREAM_DATA_SOURCE, useValue: mocks.dataSource },
{ provide: DatabaseService, useValue: mocks.databaseService },
{ provide: XtreamApiService, useValue: mocks.xtreamApiService },
{
provide: XtreamPendingRestoreService,
useValue: mocks.pendingRestoreService,
},
{ provide: DataService, useValue: mocks.dataService },
];
}
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
export function setPerformanceHook(
@@ -1,13 +1,8 @@
import { TestBed } from '@angular/core/testing';
import {
DatabaseService,
XtreamPendingRestoreService,
} from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from '../../data-sources/xtream-data-source.interface';
import { XtreamApiService } from '../../services/xtream-api.service';
import { PortalStatusType } from '../../xtream-state';
import {
createAbortError,
createContentTestProviders,
createContentTestStore,
createDeferred,
createPendingRestoreServiceMock,
@@ -60,6 +55,7 @@ describe('withContent import state', () => {
let pendingRestoreService: ReturnType<
typeof createPendingRestoreServiceMock
>;
let dataService: { sendIpcEvent: jest.Mock };
beforeEach(() => {
localStorage.clear();
@@ -95,28 +91,19 @@ describe('withContent import state', () => {
cancelSession: jest.fn().mockResolvedValue(true),
};
pendingRestoreService = createPendingRestoreServiceMock();
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ success: true }),
};
checkPortalStatusMock = jest.fn().mockResolvedValue('active');
TestBed.configureTestingModule({
providers: [
TestContentStore,
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: XtreamApiService,
useValue: xtreamApiService,
},
{
provide: XtreamPendingRestoreService,
useValue: pendingRestoreService,
},
],
providers: createContentTestProviders(TestContentStore, {
dataSource,
databaseService,
xtreamApiService,
pendingRestoreService,
dataService,
}),
});
store = TestBed.inject(TestContentStore);
@@ -18,9 +18,11 @@ import {
} from '@iptvnator/shared/logging';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
DataService,
DatabaseService,
DbOperationEvent,
isDbAbortError,
resetHostConnectivityGuard,
XtreamPendingRestoreService,
XtreamImportStatus,
} from '@iptvnator/services';
@@ -217,6 +219,7 @@ export function withContent() {
withMethods((store) => {
const dataSource = inject(XTREAM_DATA_SOURCE);
const dataService = inject(DataService);
const databaseService = inject(DatabaseService);
const pendingRestoreService = inject(XtreamPendingRestoreService);
const xtreamApiService = inject(XtreamApiService);
@@ -957,8 +960,7 @@ export function withContent() {
pendingState,
{
onEvent: trackImportEvent,
operationId:
restoreOperationId,
operationId: restoreOperationId,
}
);
throwIfImportCancelled(importSessionId);
@@ -1102,7 +1104,8 @@ export function withContent() {
'live',
{
sessionId: options?.sessionId,
onPhaseChange: publishTypedImportPhase('live'),
onPhaseChange:
publishTypedImportPhase('live'),
}
),
dataSource.getCategories(
@@ -1111,7 +1114,8 @@ export function withContent() {
'vod',
{
sessionId: options?.sessionId,
onPhaseChange: publishTypedImportPhase('vod'),
onPhaseChange:
publishTypedImportPhase('vod'),
}
),
dataSource.getCategories(
@@ -1120,7 +1124,8 @@ export function withContent() {
'series',
{
sessionId: options?.sessionId,
onPhaseChange: publishTypedImportPhase('series'),
onPhaseChange:
publishTypedImportPhase('series'),
}
),
]);
@@ -1278,7 +1283,8 @@ export function withContent() {
operationId: seriesOperationId,
sessionId: options?.sessionId,
onEvent: trackImportEvent,
onPhaseChange: publishTypedImportPhase('series'),
onPhaseChange:
publishTypedImportPhase('series'),
}
)) as XtreamSerieItem[];
throwIfImportCancelled(options?.importSessionId);
@@ -1368,6 +1374,16 @@ export function withContent() {
},
async retryContentInitialization(): Promise<void> {
// FIRST, before the status check below: that check is the
// one request a tripped connectivity guard would fast-fail,
// and its 'unavailable' verdict returns early — so a reset
// placed any later would never run and this button would
// silently do nothing for the guard's whole window.
await resetHostConnectivityGuard(
dataService,
getCredentialsFromStore()?.credentials.serverUrl
);
const portalStatus =
(await getPortalStore().checkPortalStatus?.()) ??
getPortalStore().portalStatus?.() ??
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import {
DataService,
DatabaseService,
PlaybackPositionRuntimeBridgeService,
PlaylistsService,
@@ -96,6 +97,7 @@ function configureStore(
useValue: { isEnabled: jest.fn(() => false) },
},
{ provide: XtreamPendingRestoreService, useValue: {} },
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
{ provide: XtreamUrlService, useValue: {} },
{ provide: XtreamXmltvFallbackService, useValue: {} },
{ provide: PORTAL_PLAYER, useValue: {} },
@@ -8,6 +8,8 @@ import {
XtreamApiService,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { DataService } from '@iptvnator/services';
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import { AccountInfoComponent } from './account-info.component';
describe('AccountInfoComponent', () => {
@@ -17,6 +19,7 @@ describe('AccountInfoComponent', () => {
getAccountInfo: jest.Mock;
};
let currentPlaylist: WritableSignal<null>;
let dataService: { sendIpcEvent: jest.Mock };
beforeEach(async () => {
xtreamApiService = {
@@ -35,6 +38,9 @@ describe('AccountInfoComponent', () => {
}),
};
currentPlaylist = signal(null);
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ success: true }),
};
await TestBed.configureTestingModule({
imports: [
@@ -43,6 +49,10 @@ describe('AccountInfoComponent', () => {
TranslateModule.forRoot(),
],
providers: [
{
provide: DataService,
useValue: dataService,
},
{
provide: MAT_DIALOG_DATA,
useValue: {
@@ -112,4 +122,24 @@ describe('AccountInfoComponent', () => {
expect(component.isActive()).toBe(true);
expect(component.userDetails()[0]?.tone).toBe('positive');
});
it('clears the connectivity guard before the Retry button re-reads the account', async () => {
// The account request is exactly what a tripped guard fast-fails, so a
// reset placed after it would leave Retry doing nothing for 30 seconds.
// The automatic first load deliberately does not reset.
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
xtreamApiService.getAccountInfo.mockClear();
await component.reload();
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
{ url: 'https://dialog.example.test' }
);
expect(
dataService.sendIpcEvent.mock.invocationCallOrder[0]
).toBeLessThan(
xtreamApiService.getAccountInfo.mock.invocationCallOrder[0]
);
});
});
@@ -15,6 +15,10 @@ import {
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
DataService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import {
resolveXtreamPortalStatus,
type XtreamAccountInfoDialogData,
@@ -54,6 +58,7 @@ export class AccountInfoComponent {
inject<XtreamAccountInfoDialogData | null>(MAT_DIALOG_DATA, {
optional: true,
}) ?? {};
private readonly dataService = inject(DataService);
private readonly xtreamApiService = inject(XtreamApiService);
private readonly xtreamStore = inject(XtreamStore);
private readonly logger = createLogger('XtreamAccountInfo');
@@ -217,10 +222,25 @@ export class AccountInfoComponent {
]);
constructor() {
void this.reload();
void this.load();
}
/**
* The template's Retry button. Clears the main process' connectivity guard
* first: the account request is exactly what a tripped guard fast-fails, so
* without this the button would do nothing for the guard's whole window.
* The automatic first load deliberately does not reset — only an explicit
* user action means "contact this host now".
*/
async reload(): Promise<void> {
await resetHostConnectivityGuard(
this.dataService,
this.currentPlaylist()?.serverUrl
);
await this.load();
}
private async load(): Promise<void> {
const playlist = this.currentPlaylist();
if (!playlist?.serverUrl || !playlist.username || !playlist.password) {
+1
View File
@@ -1,6 +1,7 @@
export * from './lib/catalog-title-match.service';
export * from './lib/cross-portal-similar.service';
export * from './lib/data.service';
export * from './lib/host-connectivity-reset';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/playback-position-runtime-bridge.service';
@@ -0,0 +1,52 @@
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import { resetHostConnectivityGuard } from './host-connectivity-reset';
describe('resetHostConnectivityGuard', () => {
let sendIpcEvent: jest.Mock;
beforeEach(() => {
sendIpcEvent = jest.fn().mockResolvedValue({ success: true });
});
it('asks the main process to forget the failures recorded for the host', async () => {
await resetHostConnectivityGuard(
{ sendIpcEvent },
'http://portal.example:8080'
);
expect(sendIpcEvent).toHaveBeenCalledWith(CONNECTIVITY_GUARD_RESET, {
url: 'http://portal.example:8080',
});
});
it.each([undefined, null, ''])(
'sends nothing when there is no URL to reset (%p)',
async (url) => {
// A playlist can be missing its address entirely; there is no host
// to clear then, and an empty reset would be a pointless round-trip.
await resetHostConnectivityGuard({ sendIpcEvent }, url);
expect(sendIpcEvent).not.toHaveBeenCalled();
}
);
it('never propagates a transport failure to the caller', async () => {
// The guard only ever delays a request, so a failed reset must not
// block the retry, discovery run or status check that asked for it.
sendIpcEvent.mockRejectedValue(new Error('IPC unavailable'));
await expect(
resetHostConnectivityGuard({ sendIpcEvent }, 'http://x.example')
).resolves.toBeUndefined();
});
it('tolerates the PWA no-op, where the channel is unknown', async () => {
// PwaService.sendIpcEvent returns undefined synchronously for channels
// it does not implement; nothing there records per-host failures yet.
sendIpcEvent.mockReturnValue(undefined);
await expect(
resetHostConnectivityGuard({ sendIpcEvent }, 'http://x.example')
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,33 @@
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import type { DataService } from './data.service';
/**
* Asks the main process to forget the connection failures it recorded for the
* host `url` points at, so the next request contacts it for real instead of
* being fast-failed by the connectivity guard.
*
* Send this whenever the user asked for a real attempt (portal retry, "test
* connection") or handed over an address that may now point somewhere else
* (import, edited connection, lazy repair).
*
* Always best effort: the guard only ever delays a request, so a failure here
* must never block the action that asked for it. In the PWA the channel is
* unknown and `sendIpcEvent` no-ops, which is correct — nothing there records
* per-host failures yet.
*/
export async function resetHostConnectivityGuard(
dataService: Pick<DataService, 'sendIpcEvent'>,
url: string | null | undefined
): Promise<void> {
if (!url) {
return;
}
try {
await Promise.resolve(
dataService.sendIpcEvent(CONNECTIVITY_GUARD_RESET, { url })
);
} catch {
// Deliberately silent: the caller's own request reports the real state.
}
}
@@ -4,6 +4,7 @@ import {
createEnvironmentInjector,
runInInjectionContext,
} from '@angular/core';
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import { DataService } from './data.service';
import { PortalStatusService } from './portal-status.service';
@@ -252,4 +253,56 @@ describe('PortalStatusService', () => {
service.checkPortalStatus('https://example.com', 'user', 'pass')
).resolves.toBe('active');
});
describe('host connectivity guard', () => {
beforeEach(() => {
dataService.sendIpcEvent.mockResolvedValue({
payload: { user_info: { auth: 1, status: 'Active' } },
});
});
it('clears the guard first when the user asked to test the connection', async () => {
// Otherwise "Test Connection" on a panel that just came back would
// report it unavailable without contacting it at all.
await service.checkPortalStatusDetails(
'http://example.com',
'user',
'pass',
{ skipCache: true }
);
expect(dataService.sendIpcEvent.mock.calls[0]).toEqual([
CONNECTIVITY_GUARD_RESET,
{ url: 'http://example.com' },
]);
});
it('leaves the guard alone for passive background checks', async () => {
await service.checkPortalStatusDetails(
'http://example.com',
'user',
'pass'
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
expect.anything()
);
});
it('still runs the check when clearing the guard fails', async () => {
dataService.sendIpcEvent.mockRejectedValueOnce(
new Error('IPC unavailable')
);
await expect(
service.checkPortalStatusDetails(
'http://example.com',
'user',
'pass',
{ skipCache: true }
)
).resolves.toMatchObject({ status: 'active' });
});
});
});
+10 -5
View File
@@ -6,13 +6,10 @@ import {
XtreamPortalStatusResponseLike,
} from '@iptvnator/shared/interfaces';
import { DataService } from './data.service';
import { resetHostConnectivityGuard } from './host-connectivity-reset';
export type PortalStatus =
| 'active'
| 'inactive'
| 'expired'
| 'unavailable'
| 'checking';
'active' | 'inactive' | 'expired' | 'unavailable' | 'checking';
/**
* Status plus the parsed account expiration from the same round-trip the
@@ -135,6 +132,14 @@ export class PortalStatusService {
if (pending) {
return pending;
}
} else {
// Skipping the cache means the user asked to test this portal
// right now, so the main process must forget any connection
// failures it recorded for the host and contact it for real.
await resetHostConnectivityGuard(
this.dataService,
connection.serverUrl
);
}
const request = this.fetchPortalStatus(
+1
View File
@@ -14,6 +14,7 @@ export * from './lib/epg-program.model';
export * from './lib/external-player-arguments.utils';
export * from './lib/external-player-session.interface';
export * from './lib/global-search-result.interface';
export * from './lib/host-connectivity.util';
export * from './lib/indexed-db.config';
export * from './lib/ipc-command.class';
export * from './lib/ipc-commands';
@@ -283,6 +283,13 @@ export interface ElectronBridgeStalkerRequestPayload {
token?: string;
serialNumber?: string;
requestId?: string;
/**
* Endpoint-discovery probes only: exempt this request from the main
* process' per-host connectivity guard. Discovery walks several candidate
* paths on one host and expects most of them to fail, so its failures must
* neither count towards the guard nor be fast-failed by it.
*/
skipConnectionGuard?: boolean;
}
export interface ElectronBridgeXtreamRequestPayload {
@@ -774,6 +781,11 @@ export interface ElectronBridgeApi {
stalkerRequest: (
payload: ElectronBridgeStalkerRequestPayload
) => Promise<Record<string, unknown>>;
/**
* Forgets the connection failures recorded for the host `url` points at, so
* the next request contacts it for real instead of being fast-failed.
*/
resetHostConnectivityGuard: (url: string) => Promise<ElectronBridgeResult>;
xtreamRequest: (
payload: ElectronBridgeXtreamRequestPayload
) => Promise<ElectronBridgeXtreamResponse>;
@@ -0,0 +1,49 @@
/**
* Cross-process contract for the main-process host connectivity guard.
*
* The guard fast-fails requests to a portal host that just failed to answer
* several times in a row, instead of hanging the full axios timeout again.
*
* Only the MESSAGE of a rejected IPC handler survives the trip to the
* renderer — `ipcRenderer.invoke` strips custom properties and re-wraps the
* value — and the Stalker renderer classifies transport failures purely from
* that text. The wording below is therefore a contract, not a label:
*
* - no `HTTP Error <code>`: `getStalkerRequestErrorStatus` would report a
* status, so endpoint discovery would read "this endpoint is absent, probe
* the next candidate", and 404/401/403 would additionally fire lazy portal
* repair against a host we just declared dead;
* - no timeout wording: `isStalkerProbeTimeout` would keep discovery walking
* every candidate instead of aborting on the first one;
* - none of the auth phrases `isStalkerAuthFailureMessage` accepts (a bare
* `authorization` is one of them) — that would also trigger lazy repair.
*
* What is left is the "connection-level failure" slot the renderer already
* has for ECONNREFUSED/ENOTFOUND: discovery stops probing and reports the
* host unreachable, which is exactly what a tripped guard means. The message
* also reaches error snackbars verbatim, so it has to read like a sentence.
*
* `endpoint` is the request origin (scheme, host and port), not a bare host: a
* user with the same panel imported over both HTTP and HTTPS needs to see which
* of the two was skipped. A scheme is safe here — none of the forbidden
* substrings above can appear in one.
*/
export function buildHostConnectivityFastFailMessage(endpoint: string): string {
return `Portal ${endpoint} is not responding; skipped after repeated connection failures`;
}
const FAST_FAIL_MESSAGE_PATTERN =
/is not responding; skipped after repeated connection failures/;
/**
* Whether a message came from the guard, bare or in the form Electron wraps
* it into (`Error invoking remote method 'STALKER_REQUEST': <message>`).
*
* Exported so tests and any future renderer-side handling share one
* definition of the wording instead of re-typing the sentence.
*/
export function isHostConnectivityFastFailMessage(message: unknown): boolean {
return (
typeof message === 'string' && FAST_FAIL_MESSAGE_PATTERN.test(message)
);
}
@@ -100,6 +100,14 @@ export const STALKER_REQUEST = 'STALKER_REQUEST';
export const STALKER_RESPONSE = 'STALKER_RESPONSE';
export const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
/**
* Forgets the main process' recorded connection failures for a portal host, so
* the next request contacts it for real. Sent whenever the user asks for a
* fresh attempt (portal retry, "test connection") or hands over a possibly
* different portal (endpoint discovery on import, edit, or lazy repair).
*/
export const CONNECTIVITY_GUARD_RESET = 'CONNECTIVITY_GUARD_RESET';
// Settings
export const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
export const DELETE_ALL_PLAYLISTS = 'DELETE_ALL_PLAYLISTS';
@@ -21,6 +21,8 @@
* URL builders were centralised here. Two copies would drift.
*/
import { isHostConnectivityFastFailMessage } from './host-connectivity.util';
/** The exact plain-text bodies the stock middleware emits. */
export const STALKER_AUTH_FAILURE_BODIES = [
'Authorization failed.',
@@ -122,9 +124,19 @@ function isStalkerJsonAuthFailurePhrase(value: string): boolean {
* arbitrary portal body, where the same breadth would false-positive.
*/
export function isStalkerAuthFailureMessage(message: unknown): boolean {
return (
typeof message === 'string' && isStalkerJsonAuthFailurePhrase(message)
);
if (typeof message !== 'string') {
return false;
}
// The connectivity guard's refusal names the portal endpoint, and a
// hostname is user data: `https://authorization.example` would otherwise
// match the broad phrase set below and send an unreachable host into lazy
// portal repair. An explicit marker outranks a heuristic.
if (isHostConnectivityFastFailMessage(message)) {
return false;
}
return isStalkerJsonAuthFailurePhrase(message);
}
/**