mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
commit
9f0a8b4f19
385 files changed
+58306
-2264
No files matched your search
+123
@@ -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([
|
||||
|
||||
+117
-1
@@ -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
|
||||
|
||||
+150
-12
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user