mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
perf(workspace): share the startup inventory read and defer non-first-card IPC (J1)
Journey J1 / renderer.ipcCallsToFirstCard: 12 -> 7. - PlaylistsService.getAllPlaylists() shares one in-flight SQLite read between concurrent callers (the playlist effect and the XMLTV source reconciliation at startup). Settled reads are never reused and every SQLite write detaches the pending read. - getM3uFavoriteChannels() stops re-reading the write-once IndexedDB -> SQLite migration receipt once it has been seen. - StartupDeferralService holds the download list, app update status and the dashboard's recent items and favorites until one task after the render that reveals the routed content (5 s safety timeout). IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1 is the kill switch. - reconcileEpgSources and the two distinct migration-flag reads stay on the critical path: the first is the #1548 revision fence, the second are different keys, not duplicates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
76dd8c099e
commit
e5ee064407
24 files changed
+648
-23
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: perf
|
||||
area: workspace
|
||||
---
|
||||
|
||||
The workspace shows its first source card sooner after launch (journey J1, `renderer.ipcCallsToFirstCard`): it reads the source list once instead of twice, and loads downloads, update status, recent items and favorites right after the first screen. Set `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1` to turn this off.
|
||||
@@ -122,6 +122,11 @@ const xtreamPreloadPerformanceCapture = shouldCaptureXtreamPerformance
|
||||
)
|
||||
: null;
|
||||
|
||||
/** Same spelling as the connectivity-guard kill switch: `1` or `true`. */
|
||||
function isEnvFlagSet(value: string | undefined): boolean {
|
||||
return value === '1' || value === 'true';
|
||||
}
|
||||
|
||||
function emitRendererTrace(payload: {
|
||||
method: string;
|
||||
phase: 'start' | 'success' | 'error';
|
||||
@@ -412,6 +417,9 @@ const electronApi: ElectronBridgeApi = {
|
||||
},
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
platform: process.platform,
|
||||
startupDeferralDisabled: isEnvFlagSet(
|
||||
process.env['IPTVNATOR_DISABLE_STARTUP_DEFERRAL']
|
||||
),
|
||||
getAppUpdateStatus: () => ipcRenderer.invoke(APP_UPDATE_GET_STATUS),
|
||||
checkForAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_CHECK),
|
||||
downloadAppUpdate: () => ipcRenderer.invoke(APP_UPDATE_DOWNLOAD),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
selectPlaylistsLoadFailed,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { StartupDeferralService } from '@iptvnator/services';
|
||||
import { AppStartupStatusComponent } from './app-startup-status.component';
|
||||
|
||||
describe('AppStartupStatusComponent', () => {
|
||||
@@ -48,6 +49,28 @@ describe('AppStartupStatusComponent', () => {
|
||||
expect(fixture.componentInstance.complete()).toBe(true);
|
||||
});
|
||||
|
||||
it('releases deferred startup work only after the completing render', async () => {
|
||||
const mark = jest.spyOn(
|
||||
TestBed.inject(StartupDeferralService),
|
||||
'markFirstContentRendered'
|
||||
);
|
||||
const fixture = TestBed.createComponent(AppStartupStatusComponent);
|
||||
fixture.componentRef.setInput('routeReady', true);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(mark).not.toHaveBeenCalled();
|
||||
|
||||
TestBed.inject(MockStore).overrideSelector(
|
||||
selectPlaylistsLoadingFlag,
|
||||
true
|
||||
);
|
||||
TestBed.inject(MockStore).refreshState();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(mark).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers a retry on failure without rendering raw storage errors', () => {
|
||||
TestBed.inject(MockStore).overrideSelector(
|
||||
selectPlaylistsLoadFailed,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
Injector,
|
||||
input,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
selectPlaylistsLoadFailed,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { StartupDeferralService } from '@iptvnator/services';
|
||||
|
||||
/** The first route also waits for settings and XMLTV source reconciliation. */
|
||||
@Component({
|
||||
@@ -134,6 +139,22 @@ export class AppStartupStatusComponent {
|
||||
() => this.routeReady() && this.sourcesReady()
|
||||
);
|
||||
|
||||
constructor() {
|
||||
const injector = inject(Injector);
|
||||
const startupDeferral = inject(StartupDeferralService);
|
||||
// The routed content is revealed in the render that completes the
|
||||
// startup status; deferred startup work may start after it.
|
||||
effect(() => {
|
||||
if (!this.complete()) return;
|
||||
untracked(() =>
|
||||
afterNextRender(
|
||||
() => startupDeferral.markFirstContentRendered(),
|
||||
{ injector }
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.store.dispatch(PlaylistActions.loadPlaylists());
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
STARTUP_WORK_DEFERRAL,
|
||||
StartupDeferralService,
|
||||
} from '@iptvnator/services';
|
||||
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
|
||||
import { AppUpdateReleaseNotesDialogComponent } from './settings/app-update-release-notes-dialog.component';
|
||||
|
||||
@@ -166,3 +170,76 @@ describe('AppUpdateNotificationPanelComponent', () => {
|
||||
open.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppUpdateNotificationPanelComponent at startup', () => {
|
||||
const originalElectron = window.electron;
|
||||
let statusHandler: ((status: ElectronBridgeAppUpdateStatus) => void) | null;
|
||||
|
||||
function setup() {
|
||||
statusHandler = null;
|
||||
window.electron = {
|
||||
getAppUpdateStatus: jest.fn().mockResolvedValue(availableStatus),
|
||||
onAppUpdateStatusChange: jest.fn((handler) => {
|
||||
statusHandler = handler;
|
||||
return jest.fn();
|
||||
}),
|
||||
} as unknown as typeof window.electron;
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
AppUpdateNotificationPanelComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
{ provide: STARTUP_WORK_DEFERRAL, useValue: true },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(
|
||||
AppUpdateNotificationPanelComponent
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const openGate = async () => {
|
||||
TestBed.inject(StartupDeferralService).markFirstContentRendered();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
};
|
||||
return { fixture, openGate };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('reads the update status only after the first screen rendered', async () => {
|
||||
const { fixture, openGate } = setup();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalled();
|
||||
expect(window.electron.getAppUpdateStatus).not.toHaveBeenCalled();
|
||||
|
||||
await openGate();
|
||||
|
||||
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="app-update-notification"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a status pushed while the read waited', async () => {
|
||||
const { fixture, openGate } = setup();
|
||||
statusHandler?.({
|
||||
...availableStatus,
|
||||
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded,
|
||||
});
|
||||
|
||||
await openGate();
|
||||
|
||||
expect(window.electron.getAppUpdateStatus).not.toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.status()?.status).toBe(
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { StartupDeferralService } from '@iptvnator/services';
|
||||
import { AppUpdateInstallService } from './services/app-update-install.service';
|
||||
|
||||
@Component({
|
||||
@@ -180,6 +181,7 @@ import { AppUpdateInstallService } from './services/app-update-install.service';
|
||||
export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly installService = inject(AppUpdateInstallService);
|
||||
private readonly startupDeferral = inject(StartupDeferralService);
|
||||
private unsubscribeStatus: (() => void) | null = null;
|
||||
|
||||
readonly appUpdateStatuses = ELECTRON_BRIDGE_APP_UPDATE_STATUSES;
|
||||
@@ -223,14 +225,21 @@ export class AppUpdateNotificationPanelComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
let pushed = false;
|
||||
this.unsubscribeStatus =
|
||||
window.electron.onAppUpdateStatusChange?.((status) => {
|
||||
pushed = true;
|
||||
this.status.set(status);
|
||||
}) ?? null;
|
||||
|
||||
void window.electron.getAppUpdateStatus().then((status) => {
|
||||
this.status.set(status);
|
||||
});
|
||||
// The card is never part of the first screen (journey J1). A status
|
||||
// pushed while the read waited is newer than the read would be.
|
||||
void this.startupDeferral
|
||||
.whenFirstContentRendered()
|
||||
.then(() => (pushed ? null : window.electron.getAppUpdateStatus()))
|
||||
.then((status) => {
|
||||
if (status && !pushed) this.status.set(status);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
provideParentalLockPlaylistCleanup,
|
||||
DataService,
|
||||
STARTUP_WORK_DEFERRAL,
|
||||
} from '@iptvnator/services';
|
||||
import { dbConfig } from '@iptvnator/shared/interfaces';
|
||||
import { AppConfig } from '../environments/environment';
|
||||
@@ -111,6 +112,9 @@ export function DataFactory() {
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
// Startup work the first screen does not need waits for its render
|
||||
// (journey J1); AppStartupStatusComponent opens the gate.
|
||||
{ provide: STARTUP_WORK_DEFERRAL, useValue: true },
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideAnimations(),
|
||||
// CDK overlays (menus, tooltips, dialogs) live in a container under
|
||||
|
||||
@@ -179,6 +179,35 @@ harness, which is what the ratchet needs. The main process start
|
||||
(`Date.now() - process.uptime()`) is recorded per iteration under
|
||||
`evidence.epochs` for cross-checks.
|
||||
|
||||
### Startup work before the first card
|
||||
|
||||
`renderer.ipcCallsToFirstCard` counts what the renderer asks of the main
|
||||
process before the first card. Only work that the first card needs may run
|
||||
before it:
|
||||
|
||||
- `PlaylistsService.getAllPlaylists()` shares one in-flight SQLite read
|
||||
between concurrent callers (`SharedInFlightRead`): at startup the playlist
|
||||
effect and the XMLTV source reconciliation both read the inventory, and the
|
||||
second caller joins the first read and receives a copy. A settled read is
|
||||
never reused, and every SQLite write detaches the pending read, so a caller
|
||||
that follows a write reads again.
|
||||
- `StartupDeferralService` (`@iptvnator/services`) holds work the first
|
||||
screen does not need: the initial download list (the header badge), the
|
||||
app update status (the update card), and the dashboard's recent items and
|
||||
favorites. `app.config.ts` arms it with `STARTUP_WORK_DEFERRAL`, and
|
||||
`AppStartupStatusComponent` opens it one task after the render that
|
||||
reveals the routed content, so released work never lands in the task of
|
||||
the first card. It opens on its own after 5 s if that render never
|
||||
happens. Without the token (tests, other hosts) the gate is open.
|
||||
- `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1` (or `true`) is the short-lived kill
|
||||
switch: the preload exposes it as `startupDeferralDisabled` and the gate
|
||||
starts open. Remove it within two releases of the change that added it.
|
||||
- `reconcileEpgSources` stays before the first card on purpose: its
|
||||
completion bumps `EpgSourceSettingsService.revision()`, the fence that
|
||||
keeps XMLTV lookups from returning data of a removed source. Deferring it
|
||||
would let dashboard EPG lookups start first and then be discarded and
|
||||
requeued.
|
||||
|
||||
### Summary schema
|
||||
|
||||
```json
|
||||
|
||||
@@ -282,7 +282,11 @@ hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
|
||||
`updateDate` / `importDate` for sources that have never been used.
|
||||
3. `DashboardDataService` is passive on construction. The dashboard feature
|
||||
owns the initial reloads for recent items, favorites, and Xtream recently
|
||||
added rows on page entry.
|
||||
added rows on page entry. Recent items and favorites reload through
|
||||
`reloadForPageEntry()`, which on launch waits for `StartupDeferralService`
|
||||
(the first rendered screen, journey J1); their rails keep their scoped
|
||||
skeletons meanwhile, and the Xtream recently-added and TMDB rails follow
|
||||
`globalFavoritesLoaded()` as before. Later page entries find the gate open.
|
||||
4. No dashboard-local `Layout` state, no localStorage keys, no migrations.
|
||||
Per-rail visibility is the one persisted preference, and it lives in the
|
||||
global settings store (`Settings.dashboardRails`), not in a
|
||||
|
||||
@@ -34,6 +34,7 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend
|
||||
- `IPTVNATOR_PERF_COUNT_SQL=1`, together with `IPTVNATOR_PERF_CAPTURE=1`, also counts every SQL statement of the main-process and database-worker connections for `main.sqlStatementsBeforeReadyToShow`; it wraps each statement execution, including every row of a bulk insert, so only the launch journey sets it and the import benchmarks leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
- `IPTVNATOR_DISABLE_COMPILE_CACHE=1` disables the main-process V8 compile cache; `IPTVNATOR_COMPILE_CACHE_DIR=<dir>` relocates it. The startup trace reports the outcome as `compile-cache`
|
||||
- `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1` starts the renderer's startup gate open, so the download list, update status and dashboard recent/favorites reads run before the first screen again (kill switch; see "Startup work before the first card" in `docs/architecture/performance-journeys.md`)
|
||||
|
||||
- Settings, portal request/response, and trace payloads must use
|
||||
`@iptvnator/shared/logging` or the redacting portal logger before reaching
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from './lib/playlists.service';
|
||||
export * from './lib/portal-status.service';
|
||||
export * from './lib/runtime-capabilities.service';
|
||||
export * from './lib/settings-store.service';
|
||||
export * from './lib/startup-deferral.service';
|
||||
export * from './lib/sort.service';
|
||||
export * from './lib/tmdb';
|
||||
export * from './lib/xtream-pending-restore.service';
|
||||
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
import { DownloadListLoadState } from './download-list-load-state';
|
||||
import { DownloadItem, DownloadsService } from './downloads.service';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
import {
|
||||
STARTUP_WORK_DEFERRAL,
|
||||
StartupDeferralService,
|
||||
} from './startup-deferral.service';
|
||||
|
||||
type TestDownloadsService = {
|
||||
downloads: WritableSignal<DownloadItem[]>;
|
||||
@@ -150,6 +154,7 @@ describe('DownloadsService', () => {
|
||||
const injector = createEnvironmentInjector(
|
||||
[
|
||||
DownloadsService,
|
||||
StartupDeferralService,
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsDownloads: false },
|
||||
@@ -170,6 +175,44 @@ describe('DownloadsService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('subscribes at once but loads the list after the first screen rendered', async () => {
|
||||
jest.useFakeTimers();
|
||||
const onDownloadsUpdate = jest.fn(() => () => undefined);
|
||||
const electron = {
|
||||
onDownloadsUpdate,
|
||||
downloadsGetList: jest.fn(async () => []),
|
||||
downloadsGetDefaultFolder: jest.fn(async () => '/downloads'),
|
||||
};
|
||||
testWindow.electron = electron as unknown as DownloadsElectronStub;
|
||||
const injector = createEnvironmentInjector(
|
||||
[
|
||||
DownloadsService,
|
||||
StartupDeferralService,
|
||||
{ provide: STARTUP_WORK_DEFERRAL, useValue: true },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsDownloads: true },
|
||||
},
|
||||
],
|
||||
Injector.NULL as unknown as EnvironmentInjector
|
||||
);
|
||||
|
||||
try {
|
||||
runInInjectionContext(injector, () => new DownloadsService());
|
||||
await Promise.resolve();
|
||||
expect(onDownloadsUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(electron.downloadsGetList).not.toHaveBeenCalled();
|
||||
|
||||
injector.get(StartupDeferralService).markFirstContentRendered();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(electron.downloadsGetList).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
injector.destroy();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('loads the global download list without a playlist filter and tracks request state', async () => {
|
||||
const item = createDownload(1);
|
||||
const pending = createDeferred<DownloadItem[]>();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { updateDownloadMetadata } from './downloads-metadata-update';
|
||||
import type { DownloadItem, DownloadStartInput } from './downloads.models';
|
||||
import { formatDownloadBytes } from './downloads.utils';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
import { StartupDeferralService } from './startup-deferral.service';
|
||||
|
||||
export type {
|
||||
DownloadItem,
|
||||
@@ -17,6 +18,7 @@ export type {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DownloadsService implements OnDestroy {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly startupDeferral = inject(StartupDeferralService);
|
||||
private unsubscribe?: () => void;
|
||||
private readonly downloadListLoadState = new DownloadListLoadState();
|
||||
|
||||
@@ -79,6 +81,10 @@ export class DownloadsService implements OnDestroy {
|
||||
this.loadDownloads();
|
||||
});
|
||||
|
||||
// The header badge is the only startup consumer; views that need
|
||||
// the list load it themselves. Keep it off the first screen (J1).
|
||||
await this.startupDeferral.whenFirstContentRendered();
|
||||
|
||||
// Load initial download list
|
||||
await this.loadDownloads();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { PlaylistsService } from './playlists.service';
|
||||
import { SharedInFlightRead } from './shared-in-flight-read';
|
||||
|
||||
describe('Electron legacy playlist migration', () => {
|
||||
const original = window.electron;
|
||||
@@ -38,6 +39,7 @@ describe('Electron legacy playlist migration', () => {
|
||||
dbService,
|
||||
runtime: { supportsSqlite: true },
|
||||
electronMigrationPromise: null,
|
||||
sqliteInventoryRead: new SharedInFlightRead(),
|
||||
});
|
||||
return { playlists, dbService, electron, service };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistsService } from './playlists.service';
|
||||
import { SharedInFlightRead } from './shared-in-flight-read';
|
||||
|
||||
// Performance journey J1: the playlist effect and the XMLTV source
|
||||
// reconciliation both read the inventory at startup. They must share one
|
||||
// worker round trip, without ever handing a caller a pre-write inventory.
|
||||
describe('PlaylistsService inventory reads', () => {
|
||||
const original = window.electron;
|
||||
afterEach(() => {
|
||||
window.electron = original;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => (resolve = done));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const reads: ReturnType<typeof deferred<Playlist[]>>[] = [];
|
||||
const electron = {
|
||||
dbGetAppState: jest.fn(async () => '1'),
|
||||
dbSetAppState: jest.fn(),
|
||||
dbRecoverLegacyPlaylists: jest.fn(async () => undefined),
|
||||
dbGetAppPlaylists: jest.fn(async () => []),
|
||||
dbGetAppPlaylistMetas: jest.fn(() => {
|
||||
const read = deferred<Playlist[]>();
|
||||
reads.push(read);
|
||||
return read.promise;
|
||||
}),
|
||||
dbGetAppPlaylistFavoriteChannels: jest.fn(async () => []),
|
||||
dbUpsertAppPlaylist: jest.fn(async () => undefined),
|
||||
};
|
||||
window.electron = electron as unknown as typeof window.electron;
|
||||
const service = Object.create(
|
||||
PlaylistsService.prototype
|
||||
) as PlaylistsService;
|
||||
Object.assign(service, {
|
||||
dbService: { getAll: jest.fn(() => of([])) },
|
||||
runtime: { supportsSqlite: true },
|
||||
electronMigrationPromise: null,
|
||||
sqliteInventoryRead: new SharedInFlightRead(),
|
||||
sqliteMigrationConfirmed: false,
|
||||
playlistWriteQueues: new Map(),
|
||||
});
|
||||
const settle = async (index: number, playlists: Playlist[]) => {
|
||||
// The read starts after the memoized migration's awaits.
|
||||
while (!reads[index]) await Promise.resolve();
|
||||
reads[index].resolve(playlists);
|
||||
};
|
||||
return { electron, service, settle };
|
||||
}
|
||||
|
||||
const source = (id: string) =>
|
||||
({ _id: id, title: id, favorites: ['a'] }) as unknown as Playlist;
|
||||
|
||||
it('shares one metadata read between concurrent callers', async () => {
|
||||
const { electron, service, settle } = setup();
|
||||
|
||||
const effectRead = firstValueFrom(service.getAllPlaylists());
|
||||
const reconcileRead = firstValueFrom(service.getAllPlaylists());
|
||||
await settle(0, [source('m3u')]);
|
||||
const [first, second] = await Promise.all([effectRead, reconcileRead]);
|
||||
|
||||
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(1);
|
||||
expect(second).toEqual(first);
|
||||
// The joiner holds its own copy: no caller mutates another's result.
|
||||
expect(second).not.toBe(first);
|
||||
expect(second[0].favorites).not.toBe(first[0].favorites);
|
||||
});
|
||||
|
||||
it('never reuses a settled read', async () => {
|
||||
const { electron, service, settle } = setup();
|
||||
|
||||
const first = firstValueFrom(service.getAllPlaylists());
|
||||
await settle(0, [source('before')]);
|
||||
await first;
|
||||
const second = firstValueFrom(service.getAllPlaylists());
|
||||
await settle(1, [source('after')]);
|
||||
|
||||
await expect(second).resolves.toEqual([source('after')]);
|
||||
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('starts a fresh read for callers that arrive after a write', async () => {
|
||||
const { electron, service, settle } = setup();
|
||||
const added = source('added');
|
||||
|
||||
const staleRead = firstValueFrom(service.getAllPlaylists());
|
||||
while (electron.dbGetAppPlaylistMetas.mock.calls.length === 0)
|
||||
await Promise.resolve();
|
||||
await firstValueFrom(service.addPlaylist(added));
|
||||
const freshRead = firstValueFrom(service.getAllPlaylists());
|
||||
await settle(0, []);
|
||||
await settle(1, [added]);
|
||||
|
||||
await expect(staleRead).resolves.toEqual([]);
|
||||
await expect(freshRead).resolves.toEqual([added]);
|
||||
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads the SQLite migration receipt once for M3U favorites', async () => {
|
||||
const { electron, service, settle } = setup();
|
||||
|
||||
const inventory = firstValueFrom(service.getAllPlaylists());
|
||||
await settle(0, []);
|
||||
await inventory;
|
||||
const receiptReads = electron.dbGetAppState.mock.calls.length;
|
||||
await firstValueFrom(service.getM3uFavoriteChannels('one'));
|
||||
await firstValueFrom(service.getM3uFavoriteChannels('two'));
|
||||
|
||||
expect(electron.dbGetAppState).toHaveBeenCalledTimes(receiptReads);
|
||||
expect(
|
||||
electron.dbGetAppPlaylistFavoriteChannels
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EMPTY, firstValueFrom, from, of } from 'rxjs';
|
||||
import { DbStores, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistsService, resolvePlaylistParser } from './playlists.service';
|
||||
import { SharedInFlightRead } from './shared-in-flight-read';
|
||||
|
||||
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
|
||||
@@ -60,6 +61,8 @@ describe('PlaylistsService', () => {
|
||||
},
|
||||
electronMigrationPromise: null,
|
||||
indexedDbMigrationPromise: null,
|
||||
sqliteInventoryRead: new SharedInFlightRead(),
|
||||
sqliteMigrationConfirmed: false,
|
||||
playlistWriteQueues: new Map(),
|
||||
playlistDeleteCleanups: [],
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
runWithPlaylistAuthorityReset,
|
||||
} from './playlist-cross-context-lock';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
import { SharedInFlightRead } from './shared-in-flight-read';
|
||||
|
||||
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
|
||||
@@ -116,6 +117,11 @@ export class PlaylistsService {
|
||||
private readonly playlistDeleteCleanups =
|
||||
inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? [];
|
||||
private electronMigrationPromise: Promise<void> | null = null;
|
||||
// Startup reads the inventory twice at once (the playlist effect and the
|
||||
// XMLTV source reconciliation); both share one worker round trip.
|
||||
private readonly sqliteInventoryRead = new SharedInFlightRead<Playlist[]>();
|
||||
// The IndexedDB → SQLite receipt is written once and never cleared.
|
||||
private sqliteMigrationConfirmed = false;
|
||||
private indexedDbMigrationPromise: Promise<void> | null = null;
|
||||
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -195,8 +201,10 @@ export class PlaylistsService {
|
||||
if (
|
||||
(await electron.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) ===
|
||||
'1'
|
||||
)
|
||||
) {
|
||||
this.sqliteMigrationConfirmed = true;
|
||||
return;
|
||||
}
|
||||
const playlists = await firstValueFrom(
|
||||
this.dbService.getAll<Playlist>(DbStores.Playlists)
|
||||
);
|
||||
@@ -397,6 +405,7 @@ export class PlaylistsService {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
this.sqliteInventoryRead.detach();
|
||||
if (operationId === undefined) {
|
||||
await electron.dbUpsertAppPlaylist(playlist);
|
||||
} else {
|
||||
@@ -414,6 +423,7 @@ export class PlaylistsService {
|
||||
return playlists;
|
||||
}
|
||||
|
||||
this.sqliteInventoryRead.detach();
|
||||
await electron.dbUpsertAppPlaylists(playlists);
|
||||
playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist }));
|
||||
return playlists;
|
||||
@@ -492,16 +502,22 @@ export class PlaylistsService {
|
||||
|
||||
getAllPlaylists() {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
return this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
const playlists = electron
|
||||
? await (electron.dbGetAppPlaylistMetas?.() ??
|
||||
electron.dbGetAppPlaylists())
|
||||
: [];
|
||||
return (playlists as Playlist[]).map((playlist) =>
|
||||
this.toPlaylistMeta(playlist)
|
||||
);
|
||||
});
|
||||
return from(
|
||||
this.sqliteInventoryRead.run(() =>
|
||||
firstValueFrom(
|
||||
this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
const playlists = electron
|
||||
? await (electron.dbGetAppPlaylistMetas?.() ??
|
||||
electron.dbGetAppPlaylists())
|
||||
: [];
|
||||
return (playlists as Playlist[]).map((playlist) =>
|
||||
this.toPlaylistMeta(playlist)
|
||||
);
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return this.runOnIndexedDb(() =>
|
||||
@@ -555,6 +571,7 @@ export class PlaylistsService {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
this.sqliteInventoryRead.detach();
|
||||
if (options) {
|
||||
const deleted =
|
||||
await this.databaseService.deletePlaylist(
|
||||
@@ -1072,11 +1089,14 @@ export class PlaylistsService {
|
||||
|
||||
return from(
|
||||
(async () => {
|
||||
const alreadyMigrated = await electron.dbGetAppState(
|
||||
SQLITE_PLAYLIST_MIGRATION_FLAG
|
||||
);
|
||||
if (alreadyMigrated !== '1') {
|
||||
return null;
|
||||
if (!this.sqliteMigrationConfirmed) {
|
||||
const alreadyMigrated = await electron.dbGetAppState(
|
||||
SQLITE_PLAYLIST_MIGRATION_FLAG
|
||||
);
|
||||
if (alreadyMigrated !== '1') {
|
||||
return null;
|
||||
}
|
||||
this.sqliteMigrationConfirmed = true;
|
||||
}
|
||||
|
||||
return getFavoriteChannels(playlistId);
|
||||
@@ -1326,6 +1346,7 @@ export class PlaylistsService {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
this.sqliteInventoryRead.detach();
|
||||
await electron.dbDeleteAllPlaylists();
|
||||
this.healthEvidence?.connections.next({});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Lets concurrent callers share one pending read. A settled read is never
|
||||
* reused, and `detach()` (called when a write is issued) makes the next
|
||||
* caller start a fresh read, so a caller that follows a write never receives
|
||||
* a result that was read before it. The first caller receives the read's own
|
||||
* result; callers that join it receive a copy, so no caller can mutate what
|
||||
* another one holds.
|
||||
*/
|
||||
export class SharedInFlightRead<T> {
|
||||
private pending: Promise<T> | null = null;
|
||||
|
||||
constructor(private readonly copy: (value: T) => T = copyPlainData) {}
|
||||
|
||||
run(read: () => Promise<T>): Promise<T> {
|
||||
if (this.pending) {
|
||||
return this.pending.then(this.copy);
|
||||
}
|
||||
const pending = read();
|
||||
this.pending = pending;
|
||||
const settle = () => {
|
||||
if (this.pending === pending) this.pending = null;
|
||||
};
|
||||
pending.then(settle, settle);
|
||||
return pending;
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-copies arrays and plain objects (the shape of worker results); any
|
||||
* other value, such as a `Date`, is shared by reference.
|
||||
*/
|
||||
export function copyPlainData<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => copyPlainData(item)) as T;
|
||||
}
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
Object.getPrototypeOf(value) === Object.prototype
|
||||
) {
|
||||
const copy: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
copy[key] = copyPlainData(item);
|
||||
}
|
||||
return copy as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS,
|
||||
STARTUP_WORK_DEFERRAL,
|
||||
StartupDeferralService,
|
||||
} from './startup-deferral.service';
|
||||
|
||||
describe('StartupDeferralService', () => {
|
||||
const original = window.electron;
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = original;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
function create(armed: boolean) {
|
||||
TestBed.configureTestingModule({
|
||||
providers: armed
|
||||
? [{ provide: STARTUP_WORK_DEFERRAL, useValue: true }]
|
||||
: [],
|
||||
});
|
||||
return TestBed.inject(StartupDeferralService);
|
||||
}
|
||||
|
||||
function track(service: StartupDeferralService) {
|
||||
const state = { open: false };
|
||||
void service.whenFirstContentRendered().then(() => (state.open = true));
|
||||
return state;
|
||||
}
|
||||
|
||||
it('is open when the application did not arm it', async () => {
|
||||
const state = track(create(false));
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(state.open).toBe(true);
|
||||
});
|
||||
|
||||
it('opens one task after the first content rendered', async () => {
|
||||
jest.useFakeTimers();
|
||||
const service = create(true);
|
||||
const state = track(service);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(state.open).toBe(false);
|
||||
|
||||
service.markFirstContentRendered();
|
||||
await Promise.resolve();
|
||||
// Never in the task that rendered the first card.
|
||||
expect(state.open).toBe(false);
|
||||
|
||||
jest.advanceTimersByTime(0);
|
||||
await Promise.resolve();
|
||||
expect(state.open).toBe(true);
|
||||
});
|
||||
|
||||
it('opens after the safety timeout when nothing renders', async () => {
|
||||
jest.useFakeTimers();
|
||||
const state = track(create(true));
|
||||
|
||||
jest.advanceTimersByTime(STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS - 1);
|
||||
await Promise.resolve();
|
||||
expect(state.open).toBe(false);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
await Promise.resolve();
|
||||
expect(state.open).toBe(true);
|
||||
});
|
||||
|
||||
it('honours the IPTVNATOR_DISABLE_STARTUP_DEFERRAL kill switch', async () => {
|
||||
window.electron = {
|
||||
startupDeferralDisabled: true,
|
||||
} as unknown as typeof window.electron;
|
||||
const state = track(create(true));
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(state.open).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { inject, Injectable, InjectionToken } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Arms the startup gate. Only the application shell provides `true`; any
|
||||
* other injector (unit tests, embedded hosts) sees an open gate, so deferred
|
||||
* work runs immediately there, exactly as before the gate existed.
|
||||
*/
|
||||
export const STARTUP_WORK_DEFERRAL = new InjectionToken<boolean>(
|
||||
'STARTUP_WORK_DEFERRAL'
|
||||
);
|
||||
|
||||
/**
|
||||
* Opens the gate even if the first content never renders (a failed source
|
||||
* read, a route error), so deferred background work is late, never lost.
|
||||
*/
|
||||
export const STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Holds startup work that the first rendered screen does not need until
|
||||
* that screen has rendered (performance journey J1). The gate opens one task
|
||||
* after the render in which the startup status completed, so work released
|
||||
* by it can never be issued in the same task as the first card.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StartupDeferralService {
|
||||
private readonly enabled =
|
||||
inject(STARTUP_WORK_DEFERRAL, { optional: true }) === true &&
|
||||
!isStartupDeferralDisabled();
|
||||
private open = false;
|
||||
private openGate!: () => void;
|
||||
private readonly gate = new Promise<void>((resolve) => {
|
||||
this.openGate = resolve;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
if (!this.enabled) {
|
||||
this.release();
|
||||
return;
|
||||
}
|
||||
setTimeout(() => this.release(), STARTUP_DEFERRAL_SAFETY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/** Resolves once the first routed content has rendered. */
|
||||
whenFirstContentRendered(): Promise<void> {
|
||||
return this.gate;
|
||||
}
|
||||
|
||||
/** Called after the render in which the startup status completed. */
|
||||
markFirstContentRendered(): void {
|
||||
if (!this.open) {
|
||||
setTimeout(() => this.release(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
if (this.open) return;
|
||||
this.open = true;
|
||||
this.openGate();
|
||||
}
|
||||
}
|
||||
|
||||
/** `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1`, read by the Electron preload. */
|
||||
function isStartupDeferralDisabled(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
window.electron?.startupDeferralDisabled === true
|
||||
);
|
||||
}
|
||||
@@ -710,6 +710,11 @@ export interface ElectronBridgeApi {
|
||||
) => () => void;
|
||||
getAppVersion: () => Promise<string>;
|
||||
platform: string;
|
||||
/**
|
||||
* `IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1` kill switch: issue deferred
|
||||
* startup work immediately instead of after the first rendered screen.
|
||||
*/
|
||||
startupDeferralDisabled?: boolean;
|
||||
getAppUpdateStatus: () => Promise<ElectronBridgeAppUpdateStatus>;
|
||||
checkForAppUpdate: () => Promise<ElectronBridgeAppUpdateStatus>;
|
||||
downloadAppUpdate: () => Promise<ElectronBridgeAppUpdateStatus>;
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
|
||||
import {
|
||||
DatabaseService,
|
||||
PlaylistsService,
|
||||
StartupDeferralService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
Playlist,
|
||||
@@ -270,6 +274,30 @@ describe('DashboardDataService', () => {
|
||||
expect(service.dashboardReady()).toBe(false);
|
||||
});
|
||||
|
||||
it('holds the page-entry reload until the first screen rendered', async () => {
|
||||
let openGate!: () => void;
|
||||
jest.spyOn(
|
||||
TestBed.inject(StartupDeferralService),
|
||||
'whenFirstContentRendered'
|
||||
).mockReturnValue(new Promise<void>((resolve) => (openGate = resolve)));
|
||||
const recent = jest
|
||||
.spyOn(service, 'reloadGlobalRecentItems')
|
||||
.mockResolvedValue(undefined);
|
||||
const favorites = jest
|
||||
.spyOn(service, 'reloadGlobalFavorites')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const reload = service.reloadForPageEntry();
|
||||
await Promise.resolve();
|
||||
expect(recent).not.toHaveBeenCalled();
|
||||
expect(favorites).not.toHaveBeenCalled();
|
||||
|
||||
openGate();
|
||||
await reload;
|
||||
expect(recent).toHaveBeenCalledTimes(1);
|
||||
expect(favorites).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps dashboardReady false until xtream recently added finishes its first load', async () => {
|
||||
expect(service.dashboardReady()).toBe(false);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
GlobalRecentlyAddedKind,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
StartupDeferralService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
@@ -116,6 +117,7 @@ export class DashboardDataService {
|
||||
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly startupDeferral = inject(StartupDeferralService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
@@ -499,6 +501,19 @@ export class DashboardDataService {
|
||||
|
||||
readonly quickRecent = computed(() => this.recentPlaylists().slice(0, 4));
|
||||
|
||||
/**
|
||||
* Page-entry reload of recent items and favorites. On launch it waits for
|
||||
* the first rendered screen (journey J1) and the rails keep their
|
||||
* skeletons meanwhile; later entries find the gate already open.
|
||||
*/
|
||||
async reloadForPageEntry(): Promise<void> {
|
||||
await this.startupDeferral.whenFirstContentRendered();
|
||||
await Promise.all([
|
||||
this.reloadGlobalRecentItems(),
|
||||
this.reloadGlobalFavorites(),
|
||||
]);
|
||||
}
|
||||
|
||||
async reloadGlobalRecentItems(): Promise<void> {
|
||||
if (!this.globalRecentLoaded()) {
|
||||
this.globalRecentLoadingState.set(true);
|
||||
|
||||
+1
-2
@@ -304,8 +304,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
// Re-entering the dashboard should pick up any DB-backed recent/favorite
|
||||
// changes made while viewing details, including newly backfilled
|
||||
// backdrops that do not change recency ordering.
|
||||
void this.data.reloadGlobalRecentItems();
|
||||
void this.data.reloadGlobalFavorites();
|
||||
void this.data.reloadForPageEntry();
|
||||
|
||||
this.liveEpg.connect(this.enabledLiveCards);
|
||||
|
||||
|
||||
Reference in new issue
Block a user