mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
perf(m3u): stop cancelled refresh workers
This commit is contained in:
1 parent
5940cf64e3
commit
439efe99d6
11 files changed
+357
-86
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: perf
|
||||
area: m3u
|
||||
---
|
||||
|
||||
Cancelling a large M3U refresh now stops its background worker before parsed
|
||||
channels can be copied or saved, keeping the interface responsive and leaving
|
||||
the existing playlist unchanged.
|
||||
@@ -84,6 +84,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset
|
||||
|
||||
- Settings, portal request/response, and trace payloads must use
|
||||
`@iptvnator/shared/logging` or the redacting portal logger before reaching
|
||||
|
||||
@@ -141,6 +141,7 @@ Useful narrower flags:
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset
|
||||
|
||||
Settings, portal request/response, and trace payloads must use
|
||||
`@iptvnator/shared/logging` or the redacting portal logger before reaching
|
||||
@@ -615,7 +616,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
|
||||
- EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts`
|
||||
- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`)
|
||||
- Playlist refresh: `playlist-refresh.worker.ts`
|
||||
- Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`)
|
||||
|
||||
### Key Features
|
||||
|
||||
|
||||
@@ -220,6 +220,29 @@ describe('main preload DB IPC contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a structured playlist cancellation result across the context bridge', async () => {
|
||||
const api = getExposedApi();
|
||||
const payload = {
|
||||
operationId: 'playlist-refresh-cancelled',
|
||||
playlistId: 'playlist-1',
|
||||
title: 'Large playlist',
|
||||
url: 'http://127.0.0.1/large.m3u',
|
||||
};
|
||||
const cancelledResult = {
|
||||
operationId: payload.operationId,
|
||||
type: 'playlist-refresh-cancelled',
|
||||
} as const;
|
||||
mockIpcRenderer.invoke.mockResolvedValueOnce(cancelledResult);
|
||||
|
||||
await expect(api.refreshPlaylist(payload)).resolves.toEqual(
|
||||
cancelledResult
|
||||
);
|
||||
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
|
||||
'PLAYLIST:REFRESH',
|
||||
payload
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the legacy save-content progress bridge scoped to progress events', () => {
|
||||
const api = getExposedApi();
|
||||
const callback = jest.fn();
|
||||
|
||||
@@ -527,7 +527,94 @@ describe('playlist IPC events', () => {
|
||||
expect(worker.terminate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes refresh cancellation to the active worker and converts worker error responses to Error instances', async () => {
|
||||
it('settles cancellation without waiting for a CPU-bound refresh worker', async () => {
|
||||
const ipcEvent = createIpcEvent();
|
||||
const payload: PlaylistRefreshPayload = {
|
||||
operationId: 'refresh-busy',
|
||||
playlistId: 'playlist-busy',
|
||||
title: 'Busy playlist',
|
||||
filePath: '/playlists/busy.m3u',
|
||||
};
|
||||
const refreshPromise = getHandler(PLAYLIST_REFRESH)(ipcEvent, payload);
|
||||
const worker = mockWorkerInstances[0];
|
||||
let outcome:
|
||||
| { error: unknown; status: 'rejected' }
|
||||
| { status: 'resolved'; value: unknown }
|
||||
| undefined;
|
||||
void refreshPromise.then(
|
||||
(value) => {
|
||||
outcome = { status: 'resolved', value };
|
||||
},
|
||||
(error: unknown) => {
|
||||
outcome = { error, status: 'rejected' };
|
||||
}
|
||||
);
|
||||
|
||||
worker.emit('message', { type: 'ready' });
|
||||
worker.emit('message', {
|
||||
event: {
|
||||
operationId: payload.operationId,
|
||||
phase: 'parsing',
|
||||
playlistId: payload.playlistId,
|
||||
status: 'progress',
|
||||
} satisfies PlaylistRefreshEvent,
|
||||
type: 'event',
|
||||
});
|
||||
|
||||
let finishTermination: ((exitCode: number) => void) | undefined;
|
||||
worker.terminate.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<number>((resolveTermination) => {
|
||||
finishTermination = resolveTermination;
|
||||
})
|
||||
);
|
||||
const cancelPromise = getHandler(PLAYLIST_CANCEL_REFRESH)(
|
||||
createIpcEvent(),
|
||||
payload.operationId
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(worker.removeAllListeners).toHaveBeenCalledTimes(1);
|
||||
expect(worker.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(worker.postMessage).toHaveBeenLastCalledWith({
|
||||
operationId: payload.operationId,
|
||||
type: 'cancel',
|
||||
});
|
||||
expect(outcome).toBeUndefined();
|
||||
expect(ipcEvent.sender.send).not.toHaveBeenCalledWith(
|
||||
PLAYLIST_REFRESH_EVENT,
|
||||
expect.objectContaining({ status: 'cancelled' })
|
||||
);
|
||||
|
||||
finishTermination?.(1);
|
||||
await expect(cancelPromise).resolves.toEqual({ success: true });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(outcome).toEqual({
|
||||
status: 'resolved',
|
||||
value: {
|
||||
operationId: payload.operationId,
|
||||
type: 'playlist-refresh-cancelled',
|
||||
},
|
||||
});
|
||||
expect(ipcEvent.sender.send).toHaveBeenLastCalledWith(
|
||||
PLAYLIST_REFRESH_EVENT,
|
||||
{
|
||||
operationId: payload.operationId,
|
||||
phase: 'parsing',
|
||||
playlistId: payload.playlistId,
|
||||
status: 'cancelled',
|
||||
}
|
||||
);
|
||||
await expect(
|
||||
getHandler(PLAYLIST_CANCEL_REFRESH)(
|
||||
createIpcEvent(),
|
||||
payload.operationId
|
||||
)
|
||||
).resolves.toEqual({ success: false });
|
||||
});
|
||||
|
||||
it('converts playlist refresh worker error responses to Error instances', async () => {
|
||||
const payload: PlaylistRefreshPayload = {
|
||||
operationId: 'refresh-error',
|
||||
playlistId: 'playlist-error',
|
||||
@@ -540,17 +627,6 @@ describe('playlist IPC events', () => {
|
||||
);
|
||||
const worker = mockWorkerInstances[0];
|
||||
|
||||
expect(
|
||||
await getHandler(PLAYLIST_CANCEL_REFRESH)(
|
||||
createIpcEvent(),
|
||||
'refresh-error'
|
||||
)
|
||||
).toEqual({ success: true });
|
||||
expect(worker.postMessage).toHaveBeenCalledWith({
|
||||
operationId: 'refresh-error',
|
||||
type: 'cancel',
|
||||
});
|
||||
|
||||
const rejectedRefresh = expect(refreshPromise).rejects.toMatchObject({
|
||||
message: 'Refresh failed',
|
||||
name: 'PlaylistRefreshFailure',
|
||||
@@ -568,13 +644,6 @@ describe('playlist IPC events', () => {
|
||||
});
|
||||
|
||||
await rejectedRefresh;
|
||||
|
||||
expect(
|
||||
await getHandler(PLAYLIST_CANCEL_REFRESH)(
|
||||
createIpcEvent(),
|
||||
'refresh-error'
|
||||
)
|
||||
).toEqual({ success: false });
|
||||
});
|
||||
|
||||
it('returns save dialog paths and writes files through the filesystem handler', async () => {
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
AUTO_UPDATE_PLAYLISTS,
|
||||
PLAYLIST_CANCEL_REFRESH,
|
||||
PLAYLIST_REFRESH,
|
||||
PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE,
|
||||
PLAYLIST_REFRESH_EVENT,
|
||||
ElectronBridgeTrustOptions,
|
||||
Playlist,
|
||||
PlaylistRefreshCancelledResult,
|
||||
PlaylistRefreshEvent,
|
||||
PlaylistRefreshPayload,
|
||||
summarizeAutoUpdateOutcomes,
|
||||
@@ -40,10 +42,7 @@ export default class PlaylistEvents {
|
||||
const playlistWriteAuthorizer = new PlaylistWriteAuthorizer();
|
||||
|
||||
type ActivePlaylistRefresh = {
|
||||
reject: (reason?: unknown) => void;
|
||||
resolve: (value: Playlist) => void;
|
||||
sender: WebContents;
|
||||
worker: Worker;
|
||||
cancel: () => Promise<void>;
|
||||
};
|
||||
|
||||
const activePlaylistRefreshes = new Map<string, ActivePlaylistRefresh>();
|
||||
@@ -170,76 +169,126 @@ ipcMain.handle(
|
||||
async (event, payload: PlaylistRefreshPayload) => {
|
||||
const worker = resolvePlaylistRefreshWorker();
|
||||
|
||||
return await new Promise<Playlist>((resolve, reject) => {
|
||||
const cleanup = async (): Promise<void> => {
|
||||
activePlaylistRefreshes.delete(payload.operationId);
|
||||
worker.removeAllListeners();
|
||||
await worker.terminate().catch(() => undefined);
|
||||
};
|
||||
return await new Promise<Playlist | PlaylistRefreshCancelledResult>(
|
||||
(resolve, reject) => {
|
||||
let cleanupPromise: Promise<void> | null = null;
|
||||
let lastPhase: PlaylistRefreshEvent['phase'] = payload.url
|
||||
? 'fetching'
|
||||
: 'reading-file';
|
||||
let settled = false;
|
||||
|
||||
activePlaylistRefreshes.set(payload.operationId, {
|
||||
worker,
|
||||
sender: event.sender,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
const cleanup = (): Promise<void> => {
|
||||
cleanupPromise ??= (async () => {
|
||||
activePlaylistRefreshes.delete(payload.operationId);
|
||||
worker.removeAllListeners();
|
||||
await worker.terminate().catch(() => undefined);
|
||||
})();
|
||||
return cleanupPromise;
|
||||
};
|
||||
|
||||
worker.on(
|
||||
'message',
|
||||
async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
|
||||
if (message.type === 'ready') {
|
||||
worker.postMessage({
|
||||
type: 'request',
|
||||
payload,
|
||||
});
|
||||
const cancel = async (): Promise<void> => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
|
||||
if (message.type === 'event') {
|
||||
emitPlaylistRefreshEvent(event.sender, message.event);
|
||||
return;
|
||||
try {
|
||||
worker.postMessage({
|
||||
type: 'cancel',
|
||||
operationId: payload.operationId,
|
||||
});
|
||||
} catch {
|
||||
// Termination below is authoritative even if cooperative
|
||||
// cancellation cannot be delivered.
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
emitPlaylistRefreshEvent(event.sender, {
|
||||
operationId: payload.operationId,
|
||||
playlistId: payload.playlistId,
|
||||
phase: lastPhase,
|
||||
status: 'cancelled',
|
||||
});
|
||||
resolve({
|
||||
operationId: payload.operationId,
|
||||
type: PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE,
|
||||
});
|
||||
};
|
||||
|
||||
const response =
|
||||
message as PlaylistRefreshWorkerResponseMessage<Playlist>;
|
||||
if (response.success && response.result) {
|
||||
resolve(response.result);
|
||||
const activeRefresh: ActivePlaylistRefresh = { cancel };
|
||||
activePlaylistRefreshes.set(payload.operationId, activeRefresh);
|
||||
|
||||
worker.on(
|
||||
'message',
|
||||
async (message: PlaylistRefreshWorkerMessage<Playlist>) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'ready') {
|
||||
worker.postMessage({
|
||||
type: 'request',
|
||||
payload,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'event') {
|
||||
lastPhase = message.event.phase ?? lastPhase;
|
||||
emitPlaylistRefreshEvent(
|
||||
event.sender,
|
||||
message.event
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
await cleanup();
|
||||
|
||||
const response =
|
||||
message as PlaylistRefreshWorkerResponseMessage<Playlist>;
|
||||
if (response.success && response.result) {
|
||||
resolve(response.result);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(
|
||||
createPlaylistRefreshError(
|
||||
response.error ?? {
|
||||
message:
|
||||
'Playlist refresh worker request failed',
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('error', async (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await cleanup();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
worker.on('exit', async (code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
await cleanup();
|
||||
reject(
|
||||
createPlaylistRefreshError(
|
||||
response.error ?? {
|
||||
message:
|
||||
'Playlist refresh worker request failed',
|
||||
}
|
||||
new Error(
|
||||
code === 0
|
||||
? 'Playlist refresh worker exited unexpectedly'
|
||||
: `Playlist refresh worker stopped with exit code ${code}`
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('error', async (error) => {
|
||||
await cleanup();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
worker.on('exit', async (code) => {
|
||||
if (!activePlaylistRefreshes.has(payload.operationId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await cleanup();
|
||||
reject(
|
||||
new Error(
|
||||
code === 0
|
||||
? 'Playlist refresh worker exited unexpectedly'
|
||||
: `Playlist refresh worker stopped with exit code ${code}`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -251,10 +300,7 @@ ipcMain.handle(
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
activeRefresh.worker.postMessage({
|
||||
type: 'cancel',
|
||||
operationId,
|
||||
});
|
||||
await activeRefresh.cancel();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -62,7 +62,16 @@ Two paths re-download an M3U playlist from its original source:
|
||||
|
||||
- **Explicit refresh** — `PLAYLIST_REFRESH` runs in `playlist-refresh.worker.ts`, reports
|
||||
progress through `PLAYLIST_REFRESH_EVENT`, and is cancellable via
|
||||
`PLAYLIST_CANCEL_REFRESH`.
|
||||
`PLAYLIST_CANCEL_REFRESH`. Cancellation is owned by the main process: it first
|
||||
sends the cooperative cancel message, then terminates the one-shot worker
|
||||
without waiting for its event loop. The cancel IPC resolves only after the
|
||||
worker has stopped, the correlated `cancelled` event has been emitted with the
|
||||
last known phase, and a structured cancellation result is ready. That result
|
||||
crosses both Electron IPC and the context bridge unchanged;
|
||||
`PlaylistRefreshService` converts it into a renderer-local `AbortError`.
|
||||
Relying on an error created in main or preload would lose its `name` at one of
|
||||
those serialization boundaries. A cancelled refresh must not update the
|
||||
renderer store or reach SQLite.
|
||||
- **Startup auto-update** — after `loadPlaylistsSuccess`, `AppComponent` sends
|
||||
`AUTO_UPDATE_PLAYLISTS` for every playlist with `autoRefresh === true`. The main
|
||||
process fulfils it in `playlist-auto-update.ts` on top of `playlist-source.ts`.
|
||||
@@ -87,6 +96,37 @@ dead source must never stall startup (issue #931):
|
||||
so refresh logging goes through `redactSensitiveData()` from
|
||||
`@iptvnator/shared/logging`.
|
||||
|
||||
### Refresh Cancellation Performance Regression
|
||||
|
||||
The Electron E2E project includes a deterministic 100,000-channel cancellation
|
||||
benchmark. It uses only a loopback synthetic M3U server, performs one warm-up,
|
||||
five measured runs, and one diagnostic run, and writes summaries plus raw
|
||||
profiles below the gitignored `dist/performance/` directory:
|
||||
|
||||
```bash
|
||||
perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-m3u-refresh-cancel"
|
||||
IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \
|
||||
IPTVNATOR_PERF_VARIANT=after \
|
||||
pnpm nx run electron-backend-e2e:benchmark-m3u-refresh-cancellation
|
||||
```
|
||||
|
||||
The output path must be an absolute, previously unused descendant of
|
||||
`dist/performance/`. A formal run fails on a dirty worktree and records the
|
||||
commit, source-state hash, OS/architecture, Node, Electron, and fixture identity
|
||||
in its manifest. Commit the harness first and capture `baseline` from that clean
|
||||
commit; commit the production change separately, rebuild, and capture `after`
|
||||
with the same harness and machine. Set `IPTVNATOR_PERF_SMOKE=1` for one measured
|
||||
run during harness development; smoke runs may be dirty and must not support
|
||||
before/after claims.
|
||||
|
||||
The target reserves and verifies CDP port 9222, freezes renderer long-task,
|
||||
frame-gap, and heartbeat probes before forced post-GC heap collection, and
|
||||
enables opt-in worker profiling. Worker event-loop delay is read from a
|
||||
request-scoped `node:perf_hooks` capture; a worker terminated before it can flush
|
||||
the capture reports the metric as unavailable rather than zero. Diagnostic CPU
|
||||
profiles, heap snapshots, and Chromium traces are excluded from the five-run
|
||||
headline distributions.
|
||||
|
||||
### Reporting The Auto-Update Result
|
||||
|
||||
Because auto-update isolates failures, it must also report them — otherwise a
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
ElectronBridgeApi,
|
||||
PlaylistRefreshPayload,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
import { PlaylistRefreshService } from './playlist-refresh.service';
|
||||
|
||||
describe('PlaylistRefreshService', () => {
|
||||
const originalElectron = window.electron;
|
||||
const payload: PlaylistRefreshPayload = {
|
||||
operationId: 'playlist-refresh-cancelled',
|
||||
playlistId: 'playlist-1',
|
||||
title: 'Large playlist',
|
||||
url: 'http://127.0.0.1/large.m3u',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: originalElectron,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a renderer-local AbortError from a cancellation result', async () => {
|
||||
const unsubscribe = jest.fn();
|
||||
const electron = {
|
||||
onPlaylistRefreshEvent: jest.fn(() => unsubscribe),
|
||||
refreshPlaylist: jest.fn().mockResolvedValue({
|
||||
operationId: payload.operationId,
|
||||
type: 'playlist-refresh-cancelled',
|
||||
}),
|
||||
} as unknown as ElectronBridgeApi;
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: electron,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
new PlaylistRefreshService().refreshPlaylist(payload)
|
||||
).rejects.toMatchObject({
|
||||
message:
|
||||
'Playlist refresh "playlist-refresh-cancelled" was cancelled',
|
||||
name: 'AbortError',
|
||||
});
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
isPlaylistRefreshCancelledResult,
|
||||
Playlist,
|
||||
PlaylistRefreshEvent,
|
||||
PlaylistRefreshPayload,
|
||||
@@ -30,7 +31,15 @@ export class PlaylistRefreshService {
|
||||
});
|
||||
|
||||
try {
|
||||
return await window.electron.refreshPlaylist(payload);
|
||||
const result = await window.electron.refreshPlaylist(payload);
|
||||
if (isPlaylistRefreshCancelledResult(result)) {
|
||||
const error = new Error(
|
||||
`Playlist refresh "${result.operationId}" was cancelled`
|
||||
);
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
unsubscribe?.();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
XtreamBackupRecentlyViewedItem,
|
||||
} from './playlist-backup.interface';
|
||||
import {
|
||||
PlaylistRefreshCancelledResult,
|
||||
PlaylistRefreshEvent,
|
||||
PlaylistRefreshPayload,
|
||||
} from './playlist-refresh.interface';
|
||||
@@ -671,7 +672,9 @@ export interface ElectronBridgeApi {
|
||||
url: string,
|
||||
method?: 'GET' | 'HEAD'
|
||||
) => Promise<ElectronBridgeXtreamProbeResult>;
|
||||
refreshPlaylist: (payload: PlaylistRefreshPayload) => Promise<Playlist>;
|
||||
refreshPlaylist: (
|
||||
payload: PlaylistRefreshPayload
|
||||
) => Promise<Playlist | PlaylistRefreshCancelledResult>;
|
||||
cancelPlaylistRefresh: (
|
||||
operationId: string
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
|
||||
@@ -27,3 +27,25 @@ export interface PlaylistRefreshPayload {
|
||||
url?: string;
|
||||
trustedInsecureTlsHosts?: string[];
|
||||
}
|
||||
|
||||
export const PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE =
|
||||
'playlist-refresh-cancelled' as const;
|
||||
|
||||
export interface PlaylistRefreshCancelledResult {
|
||||
operationId: string;
|
||||
type: typeof PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE;
|
||||
}
|
||||
|
||||
export function isPlaylistRefreshCancelledResult(
|
||||
value: unknown
|
||||
): value is PlaylistRefreshCancelledResult {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
candidate['type'] === PLAYLIST_REFRESH_CANCELLED_RESULT_TYPE &&
|
||||
typeof candidate['operationId'] === 'string'
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user