diff --git a/.changes/workspace-startup-first-card-ipc.md b/.changes/workspace-startup-first-card-ipc.md
new file mode 100644
index 000000000..a6e91feb9
--- /dev/null
+++ b/.changes/workspace-startup-first-card-ipc.md
@@ -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.
diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts
index 8c4557ebd..440c44551 100644
--- a/apps/electron-backend/src/app/api/main.preload.ts
+++ b/apps/electron-backend/src/app/api/main.preload.ts
@@ -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),
diff --git a/apps/web/src/app/app-startup-status.component.spec.ts b/apps/web/src/app/app-startup-status.component.spec.ts
index dae454396..ce930aa9b 100644
--- a/apps/web/src/app/app-startup-status.component.spec.ts
+++ b/apps/web/src/app/app-startup-status.component.spec.ts
@@ -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,
diff --git a/apps/web/src/app/app-startup-status.component.ts b/apps/web/src/app/app-startup-status.component.ts
index 46375638c..c841f93be 100644
--- a/apps/web/src/app/app-startup-status.component.ts
+++ b/apps/web/src/app/app-startup-status.component.ts
@@ -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());
}
diff --git a/apps/web/src/app/app-update-notification-panel.component.spec.ts b/apps/web/src/app/app-update-notification-panel.component.spec.ts
index d92cc47d9..714510fb7 100644
--- a/apps/web/src/app/app-update-notification-panel.component.spec.ts
+++ b/apps/web/src/app/app-update-notification-panel.component.spec.ts
@@ -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
+ );
+ });
+});
diff --git a/apps/web/src/app/app-update-notification-panel.component.ts b/apps/web/src/app/app-update-notification-panel.component.ts
index a99e814ad..0f4b0756f 100644
--- a/apps/web/src/app/app-update-notification-panel.component.ts
+++ b/apps/web/src/app/app-update-notification-panel.component.ts
@@ -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 {
diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts
index 1fc0d8950..7b403525c 100644
--- a/apps/web/src/app/app.config.ts
+++ b/apps/web/src/app/app.config.ts
@@ -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
diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md
index a6d59b474..ba79b4bc4 100644
--- a/docs/architecture/performance-journeys.md
+++ b/docs/architecture/performance-journeys.md
@@ -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
diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md
index 352f8cb79..158bf202b 100644
--- a/docs/architecture/workspace-dashboard.md
+++ b/docs/architecture/workspace-dashboard.md
@@ -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
diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md
index 1d88fc503..cf28059b5 100644
--- a/docs/development/electron-debugging.md
+++ b/docs/development/electron-debugging.md
@@ -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=
` 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
diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts
index 7eaa9165c..193f5b11b 100644
--- a/libs/services/src/index.ts
+++ b/libs/services/src/index.ts
@@ -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';
diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts
index 8595c4d2f..f20f52a90 100644
--- a/libs/services/src/lib/downloads.service.spec.ts
+++ b/libs/services/src/lib/downloads.service.spec.ts
@@ -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;
@@ -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();
diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts
index 7decad581..84ad72152 100644
--- a/libs/services/src/lib/downloads.service.ts
+++ b/libs/services/src/lib/downloads.service.ts
@@ -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();
diff --git a/libs/services/src/lib/playlist-migration.spec.ts b/libs/services/src/lib/playlist-migration.spec.ts
index a68e974ee..181c4ad43 100644
--- a/libs/services/src/lib/playlist-migration.spec.ts
+++ b/libs/services/src/lib/playlist-migration.spec.ts
@@ -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 };
}
diff --git a/libs/services/src/lib/playlists.service.inventory-read.spec.ts b/libs/services/src/lib/playlists.service.inventory-read.spec.ts
new file mode 100644
index 000000000..a429e7adb
--- /dev/null
+++ b/libs/services/src/lib/playlists.service.inventory-read.spec.ts
@@ -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() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((done) => (resolve = done));
+ return { promise, resolve };
+ }
+
+ function setup() {
+ const reads: ReturnType>[] = [];
+ 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();
+ 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);
+ });
+});
diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts
index 71a4961f6..ef61e091d 100644
--- a/libs/services/src/lib/playlists.service.spec.ts
+++ b/libs/services/src/lib/playlists.service.spec.ts
@@ -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: [],
});
diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts
index 9a3069fc9..1ccc170bd 100644
--- a/libs/services/src/lib/playlists.service.ts
+++ b/libs/services/src/lib/playlists.service.ts
@@ -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 | 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();
+ // The IndexedDB → SQLite receipt is written once and never cleared.
+ private sqliteMigrationConfirmed = false;
private indexedDbMigrationPromise: Promise | null = null;
private readonly playlistWriteQueues = new Map>();
@@ -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(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({});
}
diff --git a/libs/services/src/lib/shared-in-flight-read.ts b/libs/services/src/lib/shared-in-flight-read.ts
new file mode 100644
index 000000000..3289dadc8
--- /dev/null
+++ b/libs/services/src/lib/shared-in-flight-read.ts
@@ -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 {
+ private pending: Promise | null = null;
+
+ constructor(private readonly copy: (value: T) => T = copyPlainData) {}
+
+ run(read: () => Promise): Promise {
+ 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(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 = {};
+ for (const [key, item] of Object.entries(value)) {
+ copy[key] = copyPlainData(item);
+ }
+ return copy as T;
+ }
+ return value;
+}
diff --git a/libs/services/src/lib/startup-deferral.service.spec.ts b/libs/services/src/lib/startup-deferral.service.spec.ts
new file mode 100644
index 000000000..2d339e7e9
--- /dev/null
+++ b/libs/services/src/lib/startup-deferral.service.spec.ts
@@ -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);
+ });
+});
diff --git a/libs/services/src/lib/startup-deferral.service.ts b/libs/services/src/lib/startup-deferral.service.ts
new file mode 100644
index 000000000..41e3e2d08
--- /dev/null
+++ b/libs/services/src/lib/startup-deferral.service.ts
@@ -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(
+ '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((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 {
+ 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
+ );
+}
diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts
index c9d3ecd95..ce1da491f 100644
--- a/libs/shared/interfaces/src/lib/electron-api.interface.ts
+++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts
@@ -710,6 +710,11 @@ export interface ElectronBridgeApi {
) => () => void;
getAppVersion: () => Promise;
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;
checkForAppUpdate: () => Promise;
downloadAppUpdate: () => Promise;
diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts
index 51dc5212c..16743ba72 100644
--- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts
+++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts
@@ -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((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);
diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts
index 4471d1e2b..59b748cb7 100644
--- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts
+++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts
@@ -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 {
+ await this.startupDeferral.whenFirstContentRendered();
+ await Promise.all([
+ this.reloadGlobalRecentItems(),
+ this.reloadGlobalFavorites(),
+ ]);
+ }
+
async reloadGlobalRecentItems(): Promise {
if (!this.globalRecentLoaded()) {
this.globalRecentLoadingState.set(true);
diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts
index 12e4d2c7f..63beeed7b 100644
--- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts
+++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts
@@ -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);