Merge master, dedupe the kept copy, and retire superseded switches

Master had moved ten commits ahead. Two conflicts, both resolved without
losing either side: the `XTREAM_PROBE_URL` handler this PR extracted into
`events/stream-probe.ts` stays extracted (master added performance capture
nearby but never touched that handler), and the mock-server scenario table
takes the union of master's `performance` row and this branch's `multisrc`
pair.

Two findings fixed alongside it.

Pinning the copy the route is already on makes discovery return that very
row, so prepending the current source listed one stream twice — a phantom
copy in the grouping and a chip that counted it.

And handing the "playing" badge back to the route row left an in-flight
switch valid, so a slow resolution could arrive afterwards and replace the
playback the user had just started with Play, Resume or Restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 08:44:49 +02:00
commit 9f0a8b4f19
385 files changed
+58306 -2264

No files matched your search

@@ -0,0 +1,123 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistsService } from '@iptvnator/services';
import { type Channel, type Playlist } from '@iptvnator/shared/interfaces';
import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
} from '@iptvnator/shared/logging';
import { of, Subject } from 'rxjs';
import { M3uWorkspaceRouteSession } from './m3u-workspace-route-session.service';
const PLAYLIST_ID = 'performance-playlist';
const PRIMARY_CHANNEL = {
id: 'channel-1',
name: 'Sensitive channel',
url: 'https://stream-secret.example.test/1',
} as Channel;
const NEXT_CHANNEL = {
id: 'channel-2',
name: 'Next sensitive channel',
url: 'https://stream-secret.example.test/2',
} as Channel;
async function flushEffects(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe('M3uWorkspaceRouteSession performance phases', () => {
const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
const store = { dispatch: jest.fn() };
beforeEach(() => {
Object.defineProperty(window, 'electron', {
configurable: true,
value: { setUserAgent: jest.fn().mockResolvedValue(true) },
});
store.dispatch.mockReset();
TestBed.configureTestingModule({
providers: [
M3uWorkspaceRouteSession,
{
provide: PlaylistContextFacade,
useValue: {
syncFromUrl: jest.fn(() => ({
inWorkspace: true,
playlistId: PLAYLIST_ID,
provider: 'playlists',
section: 'all',
})),
},
},
{
provide: PlaylistsService,
useValue: {
getPlaylist: jest.fn(() =>
of({
playlist: {
items: [PRIMARY_CHANNEL, NEXT_CHANNEL],
},
title: 'Sensitive title',
url: 'https://user:secret@example.test/list.m3u',
} as Playlist)
),
},
},
{
provide: Router,
useValue: {
events: new Subject().asObservable(),
url: `/workspace/playlists/${PLAYLIST_ID}/all`,
},
},
{ provide: Store, useValue: store },
],
});
});
afterEach(() => {
delete (globalThis as unknown as Record<symbol, unknown>)[hookSymbol];
delete (window as unknown as { electron?: unknown }).electron;
});
it('marks channel publication with only the imported item count', async () => {
const events: RendererPerformancePhaseEvent[] = [];
(
globalThis as unknown as Record<
symbol,
(event: RendererPerformancePhaseEvent) => void
>
)[hookSymbol] = (event) => events.push(event);
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
expect(
events.map(({ boundary, metadata, outcome, phase }) => ({
boundary,
metadata,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
metadata: { items: 2 },
outcome: undefined,
phase: 'store.m3u-publish-channels',
},
{
boundary: 'end',
metadata: { items: 2 },
outcome: 'success',
phase: 'store.m3u-publish-channels',
},
]);
expect(JSON.stringify(events)).not.toContain('Sensitive');
expect(JSON.stringify(events)).not.toContain('secret');
expect(JSON.stringify(events)).not.toContain(PRIMARY_CHANNEL.url);
});
});
@@ -9,6 +9,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state';
import {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
} from '@iptvnator/shared/logging';
import { filter, firstValueFrom } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistsService } from '@iptvnator/services';
@@ -107,10 +111,16 @@ export class M3uWorkspaceRouteSession {
);
});
this.store.dispatch(
ChannelActions.setChannels({
channels: playlist.playlist?.items ?? [],
})
const channels = playlist.playlist?.items ?? [];
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.M3U_PUBLISH_CHANNELS,
() =>
this.store.dispatch(
ChannelActions.setChannels({
channels,
})
),
() => ({ items: channels.length })
);
const favorites = (playlist.favorites ?? []).filter(
@@ -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) {
@@ -206,6 +206,32 @@ describe('VodMultiSourceHostService — pinning', () => {
);
});
it('lists the route’s own copy once when the pin keeps it', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: MOVIE_A.playlistId,
contentId: MOVIE_A.contentId,
portalType: 'xtream',
});
// Pinning the copy the route is on makes discovery return that very
// row, and prepending the current source again would list one stream
// twice — a phantom copy in the grouping and a chip that counts it.
await loadMovie([
{
...ALT_TWO,
id: CURRENT_A_ID,
playlistId: MOVIE_A.playlistId,
contentId: MOVIE_A.contentId,
},
]);
expect(service.sources().map((source) => source.id)).toEqual([
CURRENT_A_ID,
]);
expect(service.hasAlternatives()).toBe(false);
});
it('hands the playing badge back when the route stream starts', async () => {
await loadMovie([ALT_TWO]);
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
@@ -289,6 +289,24 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
expect(startPlayback).not.toHaveBeenCalled();
});
it('drops a switch the route’s own Play superseded', async () => {
await loadMovie([ALT_TWO]);
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve.mockReturnValueOnce(slow.promise);
const pending = service.play(ALT_TWO.id);
// The user gave up waiting and pressed Play on the route's own stream.
// The older resolution must not come back and replace it.
service.markRouteSourceActive();
startPlayback.mockClear();
slow.resolve(resolvedFor(ALT_TWO, 0));
await expect(pending).resolves.toBe(false);
expect(startPlayback).not.toHaveBeenCalled();
});
it('drops a pin change whose movie was navigated away from', async () => {
const pinFor = (candidate: VodSourceCandidate) => ({
matchKey: 'title:the matrix:1999',
@@ -327,11 +327,9 @@ export class VodMultiSourceHostService {
}
/**
* Automatically move to the best untried source after a playback failure.
*
* Returns the notice to announce, or null when auto-failover is off or
* every source has been tried — in which case the caller shows the honest
* error screen instead.
* Move to the best untried source after a playback failure. Null when
* auto-failover is off or every source has been tried, and the caller
* shows the honest error screen instead.
*/
async failover(): Promise<VodMultiSourceSwitchNotice | null> {
if (!this.autoFailoverEnabled()) {
@@ -353,11 +351,12 @@ export class VodMultiSourceHostService {
}
/**
* Hand the "playing" badge back to the route's own row: closing an
* alternative and pressing Play starts the route stream, and the picker
* and caption must stop naming a source that is not running.
* Hand the "playing" badge back to the route's own row, and retire a
* switch still resolving — every caller is starting the route's stream,
* so an older resolution would replace what the user just asked for.
*/
markRouteSourceActive(): void {
this.switchToken++;
if (this.routeSourceId) {
this.controller.setActiveSource(this.routeSourceId);
this.publish();
@@ -38,9 +38,16 @@ export type SwitchOutcome = 'switched' | 'unresolvable' | 'superseded';
export function applyDiscoveredSources(
controller: VodMultiSourceController,
current: VodSourceCandidate,
discovered: VodSourceCandidate[],
allDiscovered: VodSourceCandidate[],
matchKind: VodSourceMatchKind
): void {
// The kept-copy exception can return the route's OWN row when the pin
// points at it, and prepending `current` again would list one stream
// twice — a phantom "copy" in the grouping and a chip counting it.
const discovered = allDiscovered.filter(
(source) => source.id !== current.id
);
const activeId = controller.activeSourceId();
// Only a source the user actually switched to needs protecting. When the
// route's own row is playing, the refreshed one supersedes it — it is the
@@ -287,3 +287,110 @@ describe('SettingsStore dashboard rail settings', () => {
);
});
});
describe('SettingsStore storage failure reporting', () => {
let injector: Injector;
let storage: {
get: jest.Mock;
set: jest.Mock;
};
let consoleError: jest.SpyInstance;
beforeEach(() => {
storage = {
get: jest.fn(() => of(null)),
set: jest.fn(() => of(undefined)),
};
consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
injector = Injector.create({
providers: [
SettingsStore,
{
provide: StorageMap,
useValue: storage,
},
],
});
});
afterEach(() => {
consoleError.mockRestore();
});
it('reports no failure while storage works', async () => {
const store = injector.get(SettingsStore);
await store.loadSettings();
await store.updateSettings({ language: Language.FRENCH });
expect(store.storageFailure()).toBeNull();
});
it('flags a failed initial load so defaults are not mistaken for saved values', async () => {
const failingSettings = new Subject<Partial<Settings> | null>();
storage.get.mockReturnValueOnce(failingSettings.asObservable());
const store = injector.get(SettingsStore);
const initialLoad = store.loadSettings();
failingSettings.error(new Error('storage unavailable'));
await initialLoad;
expect(store.storageFailure()).toBe('load');
expect(store.getSettings().language).toBe(Language.ENGLISH);
});
it('flags a failed save and rethrows instead of silently keeping the in-memory change', async () => {
storage.set.mockImplementationOnce(() => {
throw new Error('quota exceeded');
});
const store = injector.get(SettingsStore);
await store.loadSettings();
await expect(
store.updateSettings({ language: Language.FRENCH })
).rejects.toThrow('quota exceeded');
expect(store.storageFailure()).toBe('save');
// The in-memory patch still applied — that is exactly why the flag
// matters: the UI shows French but nothing reached disk.
expect(store.getSettings().language).toBe(Language.FRENCH);
});
it('clears the failure once a later save succeeds', async () => {
storage.set.mockImplementationOnce(() => {
throw new Error('quota exceeded');
});
const store = injector.get(SettingsStore);
await store.loadSettings();
await expect(
store.updateSettings({ language: Language.FRENCH })
).rejects.toThrow('quota exceeded');
expect(store.storageFailure()).toBe('save');
await store.updateSettings({ language: Language.GERMAN });
expect(store.storageFailure()).toBeNull();
});
it('clears a load failure once the retried load succeeds', async () => {
const failingSettings = new Subject<Partial<Settings> | null>();
storage.get
.mockReturnValueOnce(failingSettings.asObservable())
.mockReturnValueOnce(of({ language: Language.FRENCH }));
const store = injector.get(SettingsStore);
const initialLoad = store.loadSettings();
failingSettings.error(new Error('storage unavailable'));
await initialLoad;
expect(store.storageFailure()).toBe('load');
await store.loadSettings();
expect(store.storageFailure()).toBeNull();
expect(store.getSettings().language).toBe(Language.FRENCH);
});
});
@@ -60,6 +60,23 @@ const DEFAULT_SETTINGS: Settings = {
tmdb: DEFAULT_TMDB_SETTINGS,
};
/**
* Which half of the settings persistence round-trip failed, if any.
*
* Settings live in the renderer's IndexedDB, which can be unavailable for
* reasons the app cannot control (a second instance holding the Chromium
* storage lock, a corrupted profile, storage blocked by security software).
* Both failures used to be swallowed: `updateSettings` patches the in-memory
* state before persisting, so a failed write still looked applied until the
* next restart (issue #1156). Recording the failure lets the settings UI say
* so instead of pretending the change stuck.
*/
export type SettingsStorageFailure = 'load' | 'save';
interface SettingsStorageState {
storageFailure: SettingsStorageFailure | null;
}
let embeddedMpvPrepareScheduled = false;
function scheduleEmbeddedMpvPrepare(): void {
@@ -102,6 +119,7 @@ function scheduleEmbeddedMpvPrepare(): void {
export const SettingsStore = signalStore(
{ providedIn: 'root' },
withState<Settings>(DEFAULT_SETTINGS),
withState<SettingsStorageState>({ storageFailure: null }),
withComputed((store) => ({
/**
* Live EPG panel layout with the `'timeline'` default applied — the
@@ -125,6 +143,7 @@ export const SettingsStore = signalStore(
const stored = await firstValueFrom(
storage.get(STORE_KEY.Settings)
);
patchState(store, { storageFailure: null });
if (stored) {
const storedSettings = stored as Partial<Settings>;
patchState(store, {
@@ -148,7 +167,10 @@ export const SettingsStore = signalStore(
})().catch((error) => {
settingsLoadPromise = undefined;
console.error('Failed to load settings:', error);
// Keep default settings if loading fails
// Keep default settings if loading fails, but remember
// that they are defaults-by-failure rather than by choice
// so the settings UI can warn about it.
patchState(store, { storageFailure: 'load' });
});
return settingsLoadPromise;
@@ -177,11 +199,15 @@ export const SettingsStore = signalStore(
await firstValueFrom(
storage.set(STORE_KEY.Settings, completeSettings)
);
patchState(store, { storageFailure: null });
if (completeSettings.player === VideoPlayer.EmbeddedMpv) {
scheduleEmbeddedMpvPrepare();
}
} catch (error) {
console.error('Failed to save settings:', error);
// The in-memory patch above already applied, so without
// this flag the change looks saved until the next restart.
patchState(store, { storageFailure: 'save' });
throw error;
}
},
+2
View File
@@ -19,6 +19,7 @@ export * from './lib/ipc-commands';
export * from './lib/language.enum';
export * from './lib/m3u-favorite-channel.interface';
export * from './lib/parsed-playlist.interface';
export * from './lib/performance-phase.interface';
export * from './lib/playback-position.interface';
export * from './lib/playlist-auto-update.interface';
export * from './lib/playlist-backup.interface';
@@ -54,6 +55,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';
@@ -0,0 +1,80 @@
export const PERFORMANCE_PHASE_BOUNDARY = {
END: 'end',
START: 'start',
} as const;
export type PerformancePhaseBoundary =
(typeof PERFORMANCE_PHASE_BOUNDARY)[keyof typeof PERFORMANCE_PHASE_BOUNDARY];
export const M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL =
'IPTVNATOR_M3U_IMPORT_PERFORMANCE_PHASE_EVENT';
export const M3U_IMPORT_PERFORMANCE_PHASE = {
DATA_ACQUIRE: 'data.acquire',
NORMALIZE: 'normalize',
PARSE_M3U: 'parse.m3u',
} as const;
export type M3uImportPerformancePhase =
(typeof M3U_IMPORT_PERFORMANCE_PHASE)[keyof typeof M3U_IMPORT_PERFORMANCE_PHASE];
export const APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE = {
SERIALIZE_PLAYLIST: 'serialize.playlist',
SQLITE_WRITE: 'sqlite.write',
} as const;
export type AppPlaylistUpsertPerformancePhase =
(typeof APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE];
export const APP_PLAYLIST_GET_PERFORMANCE_PHASE = {
DESERIALIZE_PLAYLIST: 'deserialize.playlist',
SQLITE_READ: 'sqlite.read',
} as const;
export type AppPlaylistGetPerformancePhase =
(typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE];
export type AppPlaylistPerformancePhase =
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;
readonly itemCount?: number;
}
export interface PerformancePhaseEvent<TPhase extends string = string> {
readonly boundary: PerformancePhaseBoundary;
readonly durationMs: number | null;
readonly epochMs: number;
readonly metadata?: PerformancePhaseMetadata;
readonly phase: TPhase;
readonly requestId: string;
}
@@ -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;
}
+13
View File
@@ -3,3 +3,16 @@ export {
redactSensitiveData,
} from './lib/redact-sensitive-data';
export type { RedactionOptions } from './lib/redact-sensitive-data';
export {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
} from './lib/renderer-performance-phase';
export type {
RendererPerformancePhase,
RendererPerformancePhaseBoundary,
RendererPerformancePhaseEvent,
RendererPerformancePhaseHook,
RendererPerformancePhaseMetadata,
RendererPerformancePhaseOutcome,
} from './lib/renderer-performance-phase';
@@ -0,0 +1,225 @@
import * as sharedLogging from '../index';
type RendererPerformancePhase =
| '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 {
readonly boundary: 'end' | 'start';
readonly epochMs: number;
readonly metadata?: Readonly<{ items: number }>;
readonly monotonicMs: number;
readonly outcome?: RendererPerformancePhaseOutcome;
readonly phase: RendererPerformancePhase;
readonly phaseId: string;
readonly schemaVersion: 1;
}
type RendererPerformancePhaseHook = (
event: RendererPerformancePhaseEvent
) => void;
interface RendererPerformancePhaseModule {
readonly measureRendererPerformancePhase?: <T>(
phase: RendererPerformancePhase,
execute: () => T,
metadata?: () => Readonly<{ items: number }>
) => T;
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 unknown as RendererPerformancePhaseModule;
const EXPECTED_HOOK_KEY = 'iptvnator.renderer.performance-phase-hook.v1';
const hookSymbol = Symbol.for(EXPECTED_HOOK_KEY);
function measure<T>(
phase: RendererPerformancePhase,
execute: () => T,
metadata?: () => Readonly<{ items: number }>
): T {
const measurePhase = rendererPerformance.measureRendererPerformancePhase;
expect(typeof measurePhase).toBe('function');
if (measurePhase === undefined) {
throw new Error('renderer performance phase helper is unavailable');
}
return measurePhase(phase, execute, metadata);
}
function setHook(hook: RendererPerformancePhaseHook | null): void {
const target = globalThis as unknown as Record<symbol, unknown>;
if (hook === null) {
delete target[hookSymbol];
return;
}
target[hookSymbol] = hook;
}
describe('renderer performance phases', () => {
afterEach(() => {
setHook(null);
jest.restoreAllMocks();
});
it('does not evaluate metadata while the global opt-in hook is absent', () => {
const metadata = jest.fn(() => ({ items: 3 }));
const resultIdentity = { imported: true };
const result = measure(
'store.m3u-publish-channels',
() => resultIdentity,
metadata
);
expect(result).toBe(resultIdentity);
expect(metadata).not.toHaveBeenCalled();
});
it('emits an immutable successful pair with epoch and monotonic timestamps', () => {
const events: RendererPerformancePhaseEvent[] = [];
setHook((event) => events.push(event));
const resultIdentity = { imported: true };
const result = measure(
'store.m3u-publish-channels',
() => resultIdentity,
() => ({ items: 3 })
);
expect(result).toBe(resultIdentity);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
boundary: 'start',
metadata: { items: 3 },
phase: 'store.m3u-publish-channels',
schemaVersion: 1,
});
expect(events[1]).toMatchObject({
boundary: 'end',
metadata: { items: 3 },
outcome: 'success',
phase: 'store.m3u-publish-channels',
schemaVersion: 1,
});
expect(events[1].phaseId).toBe(events[0].phaseId);
expect(events[0].epochMs).toEqual(expect.any(Number));
expect(events[0].monotonicMs).toEqual(expect.any(Number));
expect(events[1].epochMs).toBeGreaterThanOrEqual(events[0].epochMs);
expect(events[1].monotonicMs).toBeGreaterThanOrEqual(
events[0].monotonicMs
);
expect(Object.isFrozen(events[0])).toBe(true);
expect(Object.isFrozen(events[0].metadata)).toBe(true);
expect(events[1].metadata).toBe(events[0].metadata);
});
it('emits an error boundary and rethrows the exact product error', () => {
const events: RendererPerformancePhaseEvent[] = [];
setHook((event) => events.push(event));
const productError = new Error('dispatch failed');
let thrown: unknown;
try {
measure('store.m3u-import-dispatch', () => {
throw productError;
});
} catch (error: unknown) {
thrown = error;
}
expect(thrown).toBe(productError);
expect(events).toHaveLength(2);
expect(events[1]).toMatchObject({
boundary: 'end',
outcome: 'error',
phase: 'store.m3u-import-dispatch',
});
expect(events[1].phaseId).toBe(events[0].phaseId);
});
it('keeps product results and errors unchanged when metadata or hooks fail', () => {
setHook(() => {
throw new Error('benchmark hook failed');
});
const resultIdentity = { imported: true };
expect(
measure(
'store.m3u-publish-channels',
() => resultIdentity,
() => {
throw new Error('metadata failed');
}
)
).toBe(resultIdentity);
const productError = new Error('product failed');
let thrown: unknown;
try {
measure('store.m3u-import-dispatch', () => {
throw productError;
});
} catch (error: unknown) {
thrown = error;
}
expect(thrown).toBe(productError);
});
it('exposes only the fixed phase vocabulary and item-count metadata', () => {
expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE_HOOK_KEY).toBe(
EXPECTED_HOOK_KEY
);
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[] = [];
setHook((event) => events.push(event));
measure(
'store.m3u-publish-channels',
() => undefined,
() =>
({
items: 2,
playlistUrl: 'https://user:secret@example.test/list.m3u',
}) as { items: number }
);
expect(events.map((event) => event.metadata)).toEqual([
{ items: 2 },
{ items: 2 },
]);
expect(JSON.stringify(events)).not.toContain('secret');
expect(JSON.stringify(events)).not.toContain('playlistUrl');
});
});
@@ -0,0 +1,189 @@
export const RENDERER_PERFORMANCE_PHASE_HOOK_KEY =
'iptvnator.renderer.performance-phase-hook.v1';
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 =
(typeof RENDERER_PERFORMANCE_PHASE)[keyof typeof RENDERER_PERFORMANCE_PHASE];
export type RendererPerformancePhaseBoundary = 'end' | 'start';
export type RendererPerformancePhaseOutcome = 'error' | 'success';
export interface RendererPerformancePhaseMetadata {
readonly items: number;
}
export interface RendererPerformancePhaseEvent {
readonly boundary: RendererPerformancePhaseBoundary;
readonly epochMs: number;
readonly metadata?: Readonly<RendererPerformancePhaseMetadata>;
readonly monotonicMs: number;
readonly outcome?: RendererPerformancePhaseOutcome;
readonly phase: RendererPerformancePhase;
readonly phaseId: string;
readonly schemaVersion: 1;
}
export type RendererPerformancePhaseHook = (
event: RendererPerformancePhaseEvent
) => void;
interface PhaseStart {
readonly epochMs: number;
readonly metadata?: Readonly<RendererPerformancePhaseMetadata>;
readonly monotonicMs: number;
readonly phase: RendererPerformancePhase;
readonly phaseId: string;
}
interface PhaseTimestamp {
readonly epochMs: number;
readonly monotonicMs: number;
}
const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
let nextPhaseId = 0;
export function measureRendererPerformancePhase<T>(
phase: RendererPerformancePhase,
execute: () => T,
createMetadata?: () => RendererPerformancePhaseMetadata
): T {
const hook = readHook();
if (hook === null) {
return execute();
}
const start = beginPhase(phase, createMetadata);
emit(hook, {
boundary: 'start',
epochMs: start.epochMs,
metadata: start.metadata,
monotonicMs: start.monotonicMs,
phase: start.phase,
phaseId: start.phaseId,
schemaVersion: 1,
});
try {
const result = execute();
endPhase(hook, start, 'success');
return result;
} catch (error) {
endPhase(hook, start, 'error');
throw error;
}
}
function beginPhase(
phase: RendererPerformancePhase,
createMetadata: (() => RendererPerformancePhaseMetadata) | undefined
): PhaseStart {
const timestamp = sampleTimestamp();
return {
...timestamp,
metadata: readMetadata(createMetadata),
phase,
phaseId: `renderer-performance-${++nextPhaseId}`,
};
}
function endPhase(
hook: RendererPerformancePhaseHook,
start: PhaseStart,
outcome: RendererPerformancePhaseOutcome
): void {
const sampled = sampleTimestamp();
emit(hook, {
boundary: 'end',
epochMs: Math.max(start.epochMs, sampled.epochMs),
metadata: start.metadata,
monotonicMs: Math.max(start.monotonicMs, sampled.monotonicMs),
outcome,
phase: start.phase,
phaseId: start.phaseId,
schemaVersion: 1,
});
}
function readHook(): RendererPerformancePhaseHook | null {
try {
const hook = (globalThis as unknown as Record<symbol, unknown>)[
hookSymbol
];
return typeof hook === 'function'
? (hook as RendererPerformancePhaseHook)
: null;
} catch {
return null;
}
}
function readMetadata(
createMetadata: (() => RendererPerformancePhaseMetadata) | undefined
): Readonly<RendererPerformancePhaseMetadata> | undefined {
if (createMetadata === undefined) {
return undefined;
}
try {
const items = createMetadata().items;
return Number.isSafeInteger(items) && items >= 0
? Object.freeze({ items })
: undefined;
} catch {
return undefined;
}
}
function emit(
hook: RendererPerformancePhaseHook,
event: RendererPerformancePhaseEvent
): void {
try {
hook(Object.freeze(event));
} catch {
// Development-only benchmark instrumentation must stay fail-neutral.
}
}
function sampleTimestamp(): PhaseTimestamp {
const fallbackMs = readDateNow();
try {
const monotonicMs = globalThis.performance?.now();
const timeOrigin = globalThis.performance?.timeOrigin;
if (!Number.isFinite(monotonicMs)) {
return { epochMs: fallbackMs, monotonicMs: fallbackMs };
}
const epochMs =
Number.isFinite(timeOrigin) && timeOrigin !== undefined
? timeOrigin + monotonicMs
: fallbackMs;
return {
epochMs: Number.isFinite(epochMs) ? epochMs : fallbackMs,
monotonicMs,
};
} catch {
return { epochMs: fallbackMs, monotonicMs: fallbackMs };
}
}
function readDateNow(): number {
try {
const value = Date.now();
return Number.isFinite(value) ? value : 0;
} catch {
return 0;
}
}