test(performance): add end-to-end Xtream benchmark harness (#1300)

* docs(performance): plan Xtream benchmark

* feat(xtream-mock-server): add deterministic 100k fixture

* style(xtream-mock-server): apply repository formatting

* fix(xtream-mock-server): harden performance fixture data

* feat(xtream-mock-server): add performance control plane

* docs(performance): correct Xtream capture plan

* fix(xtream-mock-server): harden performance controls

* fix(xtream-mock-server): harden control lifecycle

* feat(performance): add Xtream preload markers

* feat(performance): trace Xtream main phases

* feat(performance): mark Xtream store publications

* feat(performance): trace Xtream database phases

* feat(performance): trace Xtream delete cancellation

* feat(performance): capture Xtream phase attribution

* feat(performance): mark Sources Xtream refresh

* test(performance): define Xtream benchmark evidence contracts

* test(performance): add Xtream benchmark runner

* test(performance): surface failure evidence writes

* test(performance): align database read clock

* test(performance): preserve capture failure contracts
This commit is contained in:
4gray authored and GitHub committed 2026-07-28 08:08:07 +02:00
1 parent 5e725a06f3
commit a2fafcfc08
256 files changed
+43760 -883

No files matched your search

@@ -23,9 +23,26 @@ import {
PlaylistMeta,
SECURITY_ERROR_PREFIX,
} from '@iptvnator/shared/interfaces';
import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
} from '@iptvnator/shared/logging';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
function setPerformanceHook(
hook: ((event: RendererPerformancePhaseEvent) => void) | null
): void {
const target = globalThis as unknown as Record<symbol, unknown>;
if (hook === null) {
delete target[performanceHookSymbol];
} else {
target[performanceHookSymbol] = hook;
}
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -233,6 +250,7 @@ describe('PlaylistRefreshActionService', () => {
});
afterEach(() => {
setPerformanceHook(null);
jest.restoreAllMocks();
localStorage.clear();
});
@@ -382,6 +400,11 @@ describe('PlaylistRefreshActionService', () => {
it('stores Xtream restore data before updating playlist meta and navigating', async () => {
const item = createPlaylistMeta();
const executionOrder: string[] = [];
const performanceEvents: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => {
performanceEvents.push(event);
executionOrder.push(event.boundary);
});
let confirmPromise: Promise<void> | undefined;
const dateNowSpy = jest
.spyOn(Date, 'now')
@@ -456,7 +479,34 @@ describe('PlaylistRefreshActionService', () => {
'xtreams',
item._id,
]);
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
expect(executionOrder).toEqual([
'setItem',
'start',
'dispatch',
'end',
'navigate',
]);
expect(
performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({
boundary,
items: metadata?.items,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
items: 1,
outcome: undefined,
phase: 'store.xtream-refresh-meta',
},
{
boundary: 'end',
items: 1,
outcome: 'success',
phase: 'store.xtream-refresh-meta',
},
]);
setItemSpy.mockRestore();
dateNowSpy.mockRestore();
@@ -23,6 +23,10 @@ import {
PLAYLIST_UPDATE,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
} from '@iptvnator/shared/logging';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
export interface XtreamRefreshPreparationState {
@@ -159,10 +163,15 @@ export class PlaylistRefreshActionService {
playbackPositions,
});
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META,
() =>
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
),
() => ({ items: 1 })
);
await this.router.navigate([
@@ -27,8 +27,25 @@ import {
SortService,
} from '@iptvnator/services';
import { PLAYLIST_UPDATE, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
} from '@iptvnator/shared/logging';
import { RecentPlaylistsComponent } from './recent-playlists.component';
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
function setPerformanceHook(
hook: ((event: RendererPerformancePhaseEvent) => void) | null
): void {
const target = globalThis as unknown as Record<symbol, unknown>;
if (hook === null) {
delete target[performanceHookSymbol];
} else {
target[performanceHookSymbol] = hook;
}
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
@@ -233,11 +250,14 @@ describe('RecentPlaylistsComponent busy state', () => {
});
afterEach(() => {
setPerformanceHook(null);
jest.restoreAllMocks();
localStorage.clear();
});
it('tracks delete progress and clears the busy row after completion', async () => {
const events: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => events.push(event));
const item = createPlaylistMeta({ _id: 'playlist-delete-1' });
const deletion = createDeferred<boolean>();
@@ -290,9 +310,32 @@ describe('RecentPlaylistsComponent busy state', () => {
undefined,
{ duration: 2000 }
);
expect(
events.map(({ boundary, metadata, outcome, phase }) => ({
boundary,
items: metadata?.items,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
items: 1,
outcome: undefined,
phase: 'store.xtream-delete-row',
},
{
boundary: 'end',
items: 1,
outcome: 'success',
phase: 'store.xtream-delete-row',
},
]);
});
it('delegates playlist deletion and updates local UI state after success', async () => {
const events: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => events.push(event));
const item = createPlaylistMeta({
_id: 'pwa-playlist-1',
serverUrl: undefined,
@@ -316,6 +359,43 @@ describe('RecentPlaylistsComponent busy state', () => {
undefined,
{ duration: 2000 }
);
expect(events).toEqual([]);
});
it('records dispatch failure and still clears delete-row busy state', async () => {
const events: RendererPerformancePhaseEvent[] = [];
const item = createPlaylistMeta({ _id: 'dispatch-failure' });
const dispatchError = new Error('dispatch failed');
setPerformanceHook((event) => events.push(event));
(store.dispatch as jest.Mock).mockImplementation(() => {
throw dispatchError;
});
await expect(component.removePlaylist(item)).rejects.toBe(
dispatchError
);
expect(component.isDeletePending(item._id)).toBe(false);
expect(component.getBusyProgress(item._id)).toBeNull();
expect(snackBar.open).not.toHaveBeenCalled();
expect(
events.map(({ boundary, outcome, phase }) => ({
boundary,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
outcome: undefined,
phase: 'store.xtream-delete-row',
},
{
boundary: 'end',
outcome: 'error',
phase: 'store.xtream-delete-row',
},
]);
});
it('tracks Xtream refresh progress and clears the busy row after abort', async () => {
@@ -388,6 +468,11 @@ describe('RecentPlaylistsComponent busy state', () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
let confirmPromise: Promise<void> | undefined;
const executionOrder: string[] = [];
const performanceEvents: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => {
performanceEvents.push(event);
executionOrder.push(event.boundary);
});
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(1712145600000);
@@ -476,7 +561,38 @@ describe('RecentPlaylistsComponent busy state', () => {
'xtreams',
item._id,
]);
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
expect(executionOrder).toEqual([
'setItem',
'start',
'dispatch',
'end',
'navigate',
]);
expect(performanceEvents).toHaveLength(2);
expect(performanceEvents[0]?.phaseId).toBe(
performanceEvents[1]?.phaseId
);
expect(
performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({
boundary,
metadata,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
metadata: { items: 1 },
outcome: undefined,
phase: 'store.xtream-refresh-meta',
},
{
boundary: 'end',
metadata: { items: 1 },
outcome: 'success',
phase: 'store.xtream-refresh-meta',
},
]);
setItemSpy.mockRestore();
dateNowSpy.mockRestore();
@@ -46,6 +46,10 @@ import {
PlaylistMeta,
PlaylistRefreshEvent,
} from '@iptvnator/shared/interfaces';
import {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
} from '@iptvnator/shared/logging';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import type { PlaylistType } from '../add-playlist-menu/playlist-type';
@@ -271,9 +275,24 @@ export class RecentPlaylistsComponent {
}
);
if (deleted) {
this.store.dispatch(
PlaylistActions.removePlaylist({ playlistId: item._id })
);
if (item.serverUrl) {
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW,
() =>
this.store.dispatch(
PlaylistActions.removePlaylist({
playlistId: item._id,
})
),
() => ({ items: 1 })
);
} else {
this.store.dispatch(
PlaylistActions.removePlaylist({
playlistId: item._id,
})
);
}
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'
@@ -389,10 +408,15 @@ export class RecentPlaylistsComponent {
});
// Update the timestamp in NgRx / IndexedDB
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META,
() =>
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
),
() => ({ items: 1 })
);
// Navigate to the playlist to trigger re-import
@@ -1,6 +1,10 @@
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { DatabaseService } from '@iptvnator/services';
import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
} from '@iptvnator/shared/logging';
import {
XTREAM_DATA_SOURCE,
XtreamPlaylistData,
@@ -28,6 +32,18 @@ const PLAYLIST: XtreamPlaylistData = {
password: 'secret',
type: 'xtream',
};
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
function setPerformanceHook(
hook: ((event: RendererPerformancePhaseEvent) => void) | null
): void {
const target = globalThis as unknown as Record<symbol, unknown>;
if (hook === null) {
delete target[performanceHookSymbol];
} else {
target[performanceHookSymbol] = hook;
}
}
let checkPortalStatusMock: jest.Mock<Promise<PortalStatusType>, []>;
@@ -120,14 +136,16 @@ describe('withContent import state', () => {
databaseService = {
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest.fn().mockImplementation((prefix?: string) => {
if (prefix === 'xtream-import-session') {
return 'xtream-import-session';
}
createOperationId: jest
.fn()
.mockImplementation((prefix?: string) => {
if (prefix === 'xtream-import-session') {
return 'xtream-import-session';
}
operationCounter += 1;
return `${prefix ?? 'db-op'}-${operationCounter}`;
}),
operationCounter += 1;
return `${prefix ?? 'db-op'}-${operationCounter}`;
}),
getXtreamImportStatus: jest.fn().mockResolvedValue('completed'),
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
supportsDbOperationCancellation: jest.fn().mockReturnValue(true),
@@ -159,9 +177,111 @@ describe('withContent import state', () => {
});
afterEach(() => {
setPerformanceHook(null);
localStorage.clear();
});
it('emits ordered count-only publication markers for a successful import', async () => {
const events: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => events.push(event));
const categories = {
live: [{ category_id: '1' }],
vod: [{ category_id: '2' }, { category_id: '3' }],
series: [{ category_id: '4' }],
};
const content = {
live: [{ xtream_id: 1 }, { xtream_id: 2 }],
movie: [{ xtream_id: 3 }],
series: [{ xtream_id: 4 }, { xtream_id: 5 }],
};
dataSource.getCategories.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: keyof typeof categories
) => Promise.resolve(categories[type])
);
dataSource.getContent.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: keyof typeof content
) => Promise.resolve(content[type])
);
await store.initializeContent();
expect(
events.map(({ boundary, metadata, outcome, phase }) => ({
boundary,
items: metadata?.items,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
items: 4,
outcome: undefined,
phase: 'store.xtream-publish-categories',
},
{
boundary: 'end',
items: 4,
outcome: 'success',
phase: 'store.xtream-publish-categories',
},
{
boundary: 'start',
items: 2,
outcome: undefined,
phase: 'store.xtream-publish-live',
},
{
boundary: 'end',
items: 2,
outcome: 'success',
phase: 'store.xtream-publish-live',
},
{
boundary: 'start',
items: 1,
outcome: undefined,
phase: 'store.xtream-publish-vod',
},
{
boundary: 'end',
items: 1,
outcome: 'success',
phase: 'store.xtream-publish-vod',
},
{
boundary: 'start',
items: 2,
outcome: undefined,
phase: 'store.xtream-publish-series',
},
{
boundary: 'end',
items: 2,
outcome: 'success',
phase: 'store.xtream-publish-series',
},
{
boundary: 'start',
items: 5,
outcome: undefined,
phase: 'store.xtream-import-terminal',
},
{
boundary: 'end',
items: 5,
outcome: 'success',
phase: 'store.xtream-import-terminal',
},
]);
});
it('tracks aggregated import progress while content is loading', async () => {
const pendingCategories = {
live: createDeferred<any[]>(),
@@ -350,11 +470,8 @@ describe('withContent import state', () => {
) => pendingCategories[type].promise
);
dataSource.getContent.mockImplementation(
(
_playlistId: string,
_credentials: unknown,
type: ContentType
) => pending[type].promise
(_playlistId: string, _credentials: unknown, type: ContentType) =>
pending[type].promise
);
const initialization = store.initializeContent();
@@ -741,6 +858,8 @@ describe('withContent import state', () => {
});
it('ignores concurrent initializeContent calls while an import is already running', async () => {
const events: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => events.push(event));
const pendingCategories = {
live: createDeferred<any[]>(),
vod: createDeferred<any[]>(),
@@ -772,6 +891,11 @@ describe('withContent import state', () => {
expect(dataSource.getCategories).toHaveBeenCalledTimes(3);
expect(dataSource.getContent).toHaveBeenCalledTimes(3);
expect(
events.filter(
({ phase }) => phase === 'store.xtream-import-terminal'
)
).toHaveLength(2);
});
it('blocks auto restart after cancelling during category loading until retry is explicit', async () => {
@@ -936,6 +1060,8 @@ describe('withContent import state', () => {
});
it('cancels active imports and clears stale progress after worker aborts', async () => {
const events: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => events.push(event));
const pendingCategories = {
live: createDeferred<any[]>(),
vod: createDeferred<any[]>(),
@@ -1042,5 +1168,17 @@ describe('withContent import state', () => {
expect(store.activeImportTotalCount()).toBe(0);
expect(store.importCount()).toBe(0);
expect(store.itemsToImport()).toBe(0);
expect(
events
.filter(({ phase }) => phase === 'store.xtream-import-terminal')
.map(({ boundary, metadata, outcome }) => ({
boundary,
items: metadata?.items,
outcome,
}))
).toEqual([
{ boundary: 'start', items: 0, outcome: undefined },
{ boundary: 'end', items: 0, outcome: 'success' },
]);
});
});
@@ -12,6 +12,10 @@ import {
XtreamSerieItem,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
} from '@iptvnator/shared/logging';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
DatabaseService,
@@ -861,18 +865,28 @@ export function withContent() {
logger.error('Error initializing content', error);
}
} finally {
patchState(store, {
isImporting: false,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportContentType: null,
activeImportCurrentCount: 0,
activeImportTotalCount: 0,
activeImportSessionId: null,
activeImportOperationIds: [],
});
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_IMPORT_TERMINAL,
() =>
patchState(store, {
isImporting: false,
isCancellingImport: false,
importCount: 0,
importPhase: null,
itemsToImport: 0,
activeImportContentType: null,
activeImportCurrentCount: 0,
activeImportTotalCount: 0,
activeImportSessionId: null,
activeImportOperationIds: [],
}),
() => ({
items:
store.liveStreams().length +
store.vodStreams().length +
store.serialStreams().length,
})
);
}
};
@@ -962,12 +976,21 @@ export function withContent() {
),
]);
patchState(store, {
liveCategories: live,
vodCategories: vod,
serialCategories: series,
isLoadingCategories: false,
});
// This span ends after synchronous store publication;
// the renderer probe owns paint observation.
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_CATEGORIES,
() =>
patchState(store, {
liveCategories: live,
vodCategories: vod,
serialCategories: series,
isLoadingCategories: false,
}),
() => ({
items: live.length + vod.length + series.length,
})
);
} catch (error) {
if (!isDbAbortError(error)) {
logger.error('Error fetching categories', error);
@@ -1044,9 +1067,14 @@ export function withContent() {
'completed'
);
options?.completedTypes?.add('live');
patchState(store, {
liveStreams: live,
});
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_LIVE,
() =>
patchState(store, {
liveStreams: live,
}),
() => ({ items: live.length })
);
updateContentTypeLoadState('live', 'ready');
throwIfImportCancelled(options?.importSessionId);
@@ -1080,9 +1108,14 @@ export function withContent() {
'completed'
);
options?.completedTypes?.add('vod');
patchState(store, {
vodStreams: vod,
});
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_VOD,
() =>
patchState(store, {
vodStreams: vod,
}),
() => ({ items: vod.length })
);
updateContentTypeLoadState('vod', 'ready');
throwIfImportCancelled(options?.importSessionId);
@@ -1116,10 +1149,15 @@ export function withContent() {
'completed'
);
options?.completedTypes?.add('series');
patchState(store, {
serialStreams: series,
isLoadingContent: false,
});
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_SERIES,
() =>
patchState(store, {
serialStreams: series,
isLoadingContent: false,
}),
() => ({ items: series.length })
);
updateContentTypeLoadState('series', 'ready');
} catch (error) {
if (!isDbAbortError(error)) {
@@ -0,0 +1,116 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withState } from '@ngrx/signals';
import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
} from '@iptvnator/shared/logging';
import { XTREAM_DATA_SOURCE } from '../../data-sources/xtream-data-source.interface';
import { withSearch } from './with-search.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({ error: jest.fn() }),
}));
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
const TestSearchStore = signalStore(
withState({ playlistId: 'playlist-1' }),
withSearch()
);
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, reject, resolve };
}
function setPerformanceHook(
hook: ((event: RendererPerformancePhaseEvent) => void) | null
): void {
const target = globalThis as unknown as Record<symbol, unknown>;
if (hook === null) {
delete target[performanceHookSymbol];
} else {
target[performanceHookSymbol] = hook;
}
}
describe('withSearch renderer performance markers', () => {
let searchContent: jest.Mock;
let store: InstanceType<typeof TestSearchStore>;
beforeEach(() => {
searchContent = jest.fn();
TestBed.configureTestingModule({
providers: [
TestSearchStore,
{
provide: XTREAM_DATA_SOURCE,
useValue: { searchContent },
},
],
});
store = TestBed.inject(TestSearchStore);
});
afterEach(() => setPerformanceHook(null));
it('marks only the authoritative non-stale result publication', async () => {
const events: RendererPerformancePhaseEvent[] = [];
const first = createDeferred<unknown[]>();
const second = createDeferred<unknown[]>();
setPerformanceHook((event) => events.push(event));
searchContent
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const staleSearch = store.searchContent('old', ['live']);
const authoritativeSearch = store.searchContent('new', ['live']);
second.resolve([{ xtream_id: 2 }, { xtream_id: 3 }]);
await authoritativeSearch;
first.resolve([{ xtream_id: 1 }]);
await staleSearch;
expect(store.searchResults()).toEqual([
{ xtream_id: 2 },
{ xtream_id: 3 },
]);
expect(
events.map(({ boundary, metadata, outcome, phase }) => ({
boundary,
items: metadata?.items,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
items: 2,
outcome: undefined,
phase: 'store.xtream-search-results',
},
{
boundary: 'end',
items: 2,
outcome: 'success',
phase: 'store.xtream-search-results',
},
]);
});
it('does not mark an error-state reset', async () => {
const events: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => events.push(event));
searchContent.mockRejectedValue(new Error('search failed'));
await expect(store.searchContent('query', ['movie'])).resolves.toEqual(
[]
);
expect(store.searchResults()).toEqual([]);
expect(events).toEqual([]);
});
});
@@ -6,6 +6,10 @@ import {
withState,
} from '@ngrx/signals';
import { GlobalSearchResult } from '@iptvnator/shared/interfaces';
import {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
} from '@iptvnator/shared/logging';
import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
@@ -127,10 +131,15 @@ export function withSearch() {
return results;
}
patchState(store, {
searchResults: results,
isSearching: false,
});
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_SEARCH_RESULTS,
() =>
patchState(store, {
searchResults: results,
isSearching: false,
}),
() => ({ items: results.length })
);
return results;
} catch (error) {
+1
View File
@@ -52,6 +52,7 @@ 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-performance-marker.interface';
export * from './lib/xtream-serie-details.interface';
export * from './lib/xtream-serie-item.interface';
export * from './lib/xtream-vod-details.interface';
@@ -35,8 +35,35 @@ export type AppPlaylistGetPerformancePhase =
(typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE];
export type AppPlaylistPerformancePhase =
| AppPlaylistGetPerformancePhase
| AppPlaylistUpsertPerformancePhase;
AppPlaylistGetPerformancePhase | AppPlaylistUpsertPerformancePhase;
export const XTREAM_DATABASE_PERFORMANCE_PHASE = {
NORMALIZE_CATEGORIES: 'normalize.categories',
NORMALIZE_CONTENT: 'normalize.content',
NORMALIZE_SEARCH_RANK: 'normalize.search-rank',
SQLITE_CATEGORIES_READ: 'sqlite.categories.read',
SQLITE_CATEGORIES_WRITE_TRANSACTIONS:
'sqlite.categories.write-transactions',
SQLITE_CONTENT_CATEGORY_MAP_READ: 'sqlite.content.category-map-read',
SQLITE_CONTENT_READ: 'sqlite.content.read',
SQLITE_CONTENT_WRITE_TRANSACTIONS: 'sqlite.content.write-transactions',
SQLITE_PLAYLIST_DELETE_COLLECT_IDS: 'sqlite.playlist-delete.collect-ids',
SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS:
'sqlite.playlist-delete.write-transactions',
SQLITE_SEARCH_QUERY: 'sqlite.search.query',
SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS:
'sqlite.xtream-cache-clear.write-transactions',
SQLITE_XTREAM_DELETE_COLLECT_USER_DATA:
'sqlite.xtream-delete.collect-user-data',
SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS:
'sqlite.xtream-delete.write-transactions',
} as const;
export type XtreamDatabasePerformancePhase =
(typeof XTREAM_DATABASE_PERFORMANCE_PHASE)[keyof typeof XTREAM_DATABASE_PERFORMANCE_PHASE];
export type DatabaseWorkerPerformancePhase =
AppPlaylistPerformancePhase | XtreamDatabasePerformancePhase;
export interface PerformancePhaseMetadata {
readonly byteCount?: number;
@@ -0,0 +1,58 @@
import { XtreamCodeActions } from './xtream-code-actions';
export const XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL =
'IPTVNATOR_XTREAM_MAIN_PERFORMANCE_PHASE_EVENT';
export const XTREAM_MAIN_PERFORMANCE_PHASE = {
CANCEL_SESSION: 'xtream.cancel-session',
JSON_TRANSFORM: 'xtream.json.transform',
NETWORK_TOTAL: 'xtream.network.total',
RESPONSE_READY: 'xtream.response.ready',
} as const;
export type XtreamMainPerformancePhase =
(typeof XTREAM_MAIN_PERFORMANCE_PHASE)[keyof typeof XTREAM_MAIN_PERFORMANCE_PHASE];
export const XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL =
'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER';
export const XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION = 1 as const;
export const XTREAM_PRELOAD_PERFORMANCE_METHOD = {
DB_CANCEL_OPERATION: 'dbCancelOperation',
DB_DELETE_PLAYLIST: 'dbDeletePlaylist',
DB_DELETE_XTREAM_CONTENT: 'dbDeleteXtreamContent',
DB_GET_CATEGORIES: 'dbGetCategories',
DB_GET_CONTENT: 'dbGetContent',
DB_GLOBAL_SEARCH: 'dbGlobalSearch',
DB_RESTORE_XTREAM_USER_DATA: 'dbRestoreXtreamUserData',
DB_SAVE_CATEGORIES: 'dbSaveCategories',
DB_SAVE_CONTENT: 'dbSaveContent',
DB_SEARCH_CONTENT: 'dbSearchContent',
XTREAM_CANCEL_SESSION: 'xtreamCancelSession',
XTREAM_REQUEST: 'xtreamRequest',
} as const;
export type XtreamPreloadPerformanceMethod =
(typeof XTREAM_PRELOAD_PERFORMANCE_METHOD)[keyof typeof XTREAM_PRELOAD_PERFORMANCE_METHOD];
export type XtreamPreloadPerformanceBoundary = 'start' | 'end';
export type XtreamPreloadPerformanceOutcome = 'success' | 'error' | null;
export type XtreamPreloadPerformanceCategoryType = 'live' | 'movies' | 'series';
export type XtreamPreloadPerformanceContentType = 'live' | 'movie' | 'series';
export interface XtreamPreloadPerformanceMarker {
schemaVersion: typeof XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION;
sourceEpochMs: number;
ipcCallId: number;
method: XtreamPreloadPerformanceMethod;
boundary: XtreamPreloadPerformanceBoundary;
outcome: XtreamPreloadPerformanceOutcome;
playlistId: string | null;
operationId: string | null;
sessionId: string | null;
action: XtreamCodeActions | null;
categoryType: XtreamPreloadPerformanceCategoryType | null;
contentType: XtreamPreloadPerformanceContentType | null;
itemCount: number | null;
}
@@ -1,7 +1,16 @@
import * as sharedLogging from '../index';
type RendererPerformancePhase =
'store.m3u-import-dispatch' | 'store.m3u-publish-channels';
| 'store.m3u-import-dispatch'
| 'store.m3u-publish-channels'
| 'store.xtream-delete-row'
| 'store.xtream-import-terminal'
| 'store.xtream-publish-categories'
| 'store.xtream-publish-live'
| 'store.xtream-publish-series'
| 'store.xtream-publish-vod'
| 'store.xtream-refresh-meta'
| 'store.xtream-search-results';
type RendererPerformancePhaseOutcome = 'error' | 'success';
interface RendererPerformancePhaseEvent {
@@ -28,11 +37,20 @@ interface RendererPerformancePhaseModule {
readonly RENDERER_PERFORMANCE_PHASE?: Readonly<{
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch';
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels';
XTREAM_DELETE_ROW: 'store.xtream-delete-row';
XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal';
XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories';
XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live';
XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series';
XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod';
XTREAM_REFRESH_META: 'store.xtream-refresh-meta';
XTREAM_SEARCH_RESULTS: 'store.xtream-search-results';
}>;
readonly RENDERER_PERFORMANCE_PHASE_HOOK_KEY?: string;
}
const rendererPerformance = sharedLogging as RendererPerformancePhaseModule;
const rendererPerformance =
sharedLogging as unknown as RendererPerformancePhaseModule;
const EXPECTED_HOOK_KEY = 'iptvnator.renderer.performance-phase-hook.v1';
const hookSymbol = Symbol.for(EXPECTED_HOOK_KEY);
@@ -175,6 +193,14 @@ describe('renderer performance phases', () => {
expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE).toEqual({
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch',
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels',
XTREAM_DELETE_ROW: 'store.xtream-delete-row',
XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal',
XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories',
XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live',
XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series',
XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod',
XTREAM_REFRESH_META: 'store.xtream-refresh-meta',
XTREAM_SEARCH_RESULTS: 'store.xtream-search-results',
});
const events: RendererPerformancePhaseEvent[] = [];
@@ -4,6 +4,14 @@ export const RENDERER_PERFORMANCE_PHASE_HOOK_KEY =
export const RENDERER_PERFORMANCE_PHASE = {
M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch',
M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels',
XTREAM_DELETE_ROW: 'store.xtream-delete-row',
XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal',
XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories',
XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live',
XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series',
XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod',
XTREAM_REFRESH_META: 'store.xtream-refresh-meta',
XTREAM_SEARCH_RESULTS: 'store.xtream-search-results',
} as const;
export type RendererPerformancePhase =