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(