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

+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;
}
}