From 9f950a153042af14caed5c8b596bc3d3d41c418e Mon Sep 17 00:00:00 2001
From: 4gray <4gray@users.noreply.github.com>
Date: Mon, 7 Sep 2026 21:24:34 +0200
Subject: [PATCH 1/3] fix(migration): show startup preparation and recover
source read failures (#1568)
* fix(migration): show startup preparation and recover source read failures
* fix(migration): keep inventory reload failures recoverable
* test(migration): type the deferred recovery hook
* fix(migration): reconcile failed EPG cleanup before recovery
* fix(migration): await settings before inventory readiness
* style(migration): add a subtle theme-aware startup watermark
---
.changes/migration-startup-feedback.md | 6 +
AGENTS.md | 8 +
CLAUDE.md | 8 +
.../src/legacy-playlist-migration.e2e.ts | 306 +++++++++++++++++-
.../app/app-startup-status.component.spec.ts | 66 ++++
.../src/app/app-startup-status.component.ts | 140 ++++++++
apps/web/src/app/app.component.html | 5 +-
apps/web/src/app/app.component.ts | 12 +-
apps/web/src/assets/i18n/ar.json | 6 +
apps/web/src/assets/i18n/ary.json | 6 +
apps/web/src/assets/i18n/by.json | 6 +
apps/web/src/assets/i18n/de.json | 6 +
apps/web/src/assets/i18n/el.json | 6 +
apps/web/src/assets/i18n/en.json | 6 +
apps/web/src/assets/i18n/es.json | 6 +
apps/web/src/assets/i18n/fr.json | 6 +
apps/web/src/assets/i18n/hu.json | 6 +
apps/web/src/assets/i18n/it.json | 6 +
apps/web/src/assets/i18n/ja.json | 6 +
apps/web/src/assets/i18n/ko.json | 6 +
apps/web/src/assets/i18n/nl.json | 6 +
apps/web/src/assets/i18n/pl.json | 6 +
apps/web/src/assets/i18n/pt.json | 6 +
apps/web/src/assets/i18n/ru.json | 6 +
apps/web/src/assets/i18n/tr.json | 6 +
apps/web/src/assets/i18n/zh.json | 6 +
apps/web/src/assets/i18n/zhtw.json | 6 +
docs/architecture/m3u-playlist-module.md | 32 +-
libs/m3u-state/src/lib/actions.ts | 1 +
libs/m3u-state/src/lib/effects.ts | 23 +-
.../src/lib/load-playlists.effect.spec.ts | 174 ++++++++++
libs/m3u-state/src/lib/playlists.state.ts | 2 +
.../lib/reducers/playlist.reducers.spec.ts | 40 +++
.../src/lib/reducers/playlist.reducers.ts | 13 +
libs/m3u-state/src/lib/selectors.ts | 5 +
.../lib/epg-source-settings.service.spec.ts | 68 +++-
.../src/lib/epg-source-settings.service.ts | 15 +
37 files changed, 1024 insertions(+), 14 deletions(-)
create mode 100644 .changes/migration-startup-feedback.md
create mode 100644 apps/web/src/app/app-startup-status.component.spec.ts
create mode 100644 apps/web/src/app/app-startup-status.component.ts
create mode 100644 libs/m3u-state/src/lib/load-playlists.effect.spec.ts
diff --git a/.changes/migration-startup-feedback.md b/.changes/migration-startup-feedback.md
new file mode 100644
index 000000000..97abad880
--- /dev/null
+++ b/.changes/migration-startup-feedback.md
@@ -0,0 +1,6 @@
+---
+type: fix
+area: migration
+---
+
+Startup now shows a theme-aware preparation screen while loading sources and checking a large programme guide. Temporary source-read failures retry automatically; if loading still fails, you can retry without restarting the app. Recovery also completes any previously failed programme-guide cleanup before opening the library.
diff --git a/AGENTS.md b/AGENTS.md
index ba3fcb7ae..7bbf8052e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -122,6 +122,14 @@ retained, current payload rows are preserved, and completed imports never
replay deleted sources. Contract and recovery limits:
`docs/architecture/m3u-playlist-module.md` (Desktop upgrades from legacy profiles).
+Startup shows `AppStartupStatusComponent` until the initial route and source
+inventory are ready, including XMLTV reconciliation. Inventory reads retry once;
+failed reads show an explicit Retry action instead of an empty library. Successful
+inventory reads first await settings loading, then pending XMLTV reconciliation,
+and retry failed cleanup
+with its last committed URLs before exposing the workspace. See the
+same contract for startup readiness and error handling.
+
## Electron Debugging (CDP)
- Start the Electron development app with: `nx serve electron-backend`
diff --git a/CLAUDE.md b/CLAUDE.md
index 4f0800b2d..5ed3973a9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -336,6 +336,14 @@ retained, current payload rows are preserved, and completed imports never
replay deleted sources. Contract and recovery limits:
`docs/architecture/m3u-playlist-module.md` (Desktop upgrades from legacy profiles).
+Startup shows `AppStartupStatusComponent` until the initial route and source
+inventory are ready, including XMLTV reconciliation. Inventory reads retry once;
+failed reads show an explicit Retry action instead of an empty library. Successful
+inventory reads first await settings loading, then pending XMLTV reconciliation,
+and retry failed cleanup
+with its last committed URLs before exposing the workspace. See the
+same contract for startup readiness and error handling.
+
## Architecture
### Monorepo Structure (Nx Workspace)
diff --git a/apps/electron-backend-e2e/src/legacy-playlist-migration.e2e.ts b/apps/electron-backend-e2e/src/legacy-playlist-migration.e2e.ts
index 3ca9e6fa4..1e91bd8dc 100644
--- a/apps/electron-backend-e2e/src/legacy-playlist-migration.e2e.ts
+++ b/apps/electron-backend-e2e/src/legacy-playlist-migration.e2e.ts
@@ -4,7 +4,7 @@ import {
Page,
} from '@playwright/test';
import { join } from 'path';
-import { writeFile } from 'fs/promises';
+import { mkdir, readFile, writeFile } from 'fs/promises';
import {
test,
expect,
@@ -14,8 +14,18 @@ import {
electronMainPath,
workspaceRoot,
openSources,
+ openSettings,
+ openSettingsSection,
} from './electron-test-fixtures';
import { seedLegacyProfile, legacyPlaylists } from './legacy-profile-fixture';
+import { applyTheme } from './theme-contrast';
+
+interface StartupTestGlobals {
+ __failPlaylistReads: boolean;
+ __deferRecoveredEpg: boolean;
+ __releaseStartupEpg: () => void;
+ __resolveLegacyRecoveryDialog: () => void;
+}
const migrationKey = 'm3u-playlists-indexeddb-to-sqlite-v1';
const recoveryKey = 'playlists-electron-backend-profile-v1';
@@ -56,12 +66,23 @@ async function sourceCount(page: Page) {
async function launchWithRecoveryChoice(
dataDir: string,
response: number,
- retry = false
+ retry = false,
+ deferChoice = false,
+ startupFault?: 'defer-epg' | 'fail-playlist-reads'
) {
const wrapper = join(dataDir, 'recovery-launch.cjs');
await writeFile(
wrapper,
- `const {dialog}=require('electron'); dialog.showMessageBox=async()=>({response:${response},checkboxChecked:false}); require(${JSON.stringify(electronMainPath)});`
+ `const {dialog,ipcMain}=require('electron');
+dialog.showMessageBox=()=>new Promise(resolve=>{const choose=()=>resolve({response:${response},checkboxChecked:false}); ${deferChoice ? 'globalThis.__resolveLegacyRecoveryDialog=choose;' : 'choose();'}});
+globalThis.__failPlaylistReads=${startupFault === 'fail-playlist-reads'};
+const handle=ipcMain.handle.bind(ipcMain);
+ipcMain.handle=(channel,handler)=>handle(channel,async(...args)=>{
+ if(channel==='DB_GET_APP_PLAYLIST_METAS' && globalThis.__failPlaylistReads) throw new Error('Synthetic playlist read failure');
+ if(channel==='EPG_RECONCILE_SOURCES' && (${startupFault === 'defer-epg'} || globalThis.__deferRecoveredEpg)) await new Promise(resolve=>{globalThis.__releaseStartupEpg=resolve;});
+ return handler(...args);
+});
+require(${JSON.stringify(electronMainPath)});`
);
const app = await electron.launch({
args: [
@@ -74,14 +95,258 @@ async function launchWithRecoveryChoice(
env: buildElectronLaunchEnvironment(dataDir),
});
const page = await app.firstWindow();
- await page.waitForSelector('app-root');
- await page.waitForFunction(
- () => typeof window.electron?.dbGetAppPlaylists === 'function'
+ try {
+ // The route is intentionally still empty while recovery is pending.
+ await page.waitForSelector('app-root', { state: 'attached' });
+ await page.waitForFunction(
+ () => typeof window.electron?.dbGetAppPlaylists === 'function'
+ );
+ return { app, page };
+ } catch (error) {
+ await closeElectronApp({ electronApp: app, mainWindow: page });
+ throw error;
+ }
+}
+
+/** Exercise the same failure after a real backup import clears a loaded store. */
+async function verifyBackupReloadRecovery(
+ app: ElectronApplication,
+ page: Page,
+ dataDir: string
+) {
+ const backupPath = join(dataDir, 'startup-retry-backup.json');
+ await app.evaluate(({ dialog }, filePath) => {
+ dialog.showSaveDialog = async () => ({ canceled: false, filePath });
+ }, backupPath);
+ await openSettings(page);
+ await openSettingsSection(page, 'backup');
+ const backup = page.locator('#backup');
+ await backup.getByRole('button', { name: 'Export', exact: true }).click();
+ await expect(page.getByText('Playlist backup exported.')).toBeVisible();
+ await app.evaluate(() => {
+ (globalThis as typeof globalThis & StartupTestGlobals)[
+ '__failPlaylistReads'
+ ] = true;
+ });
+ const chooser = page.waitForEvent('filechooser');
+ await backup.getByRole('button', { name: 'Import', exact: true }).click();
+ await (await chooser).setFiles(backupPath);
+ const startup = page.locator('app-startup-status');
+ await expect(startup.getByRole('alert')).toContainText(
+ 'Your sources could not be loaded'
);
- return { app, page };
+ await app.evaluate(() => {
+ (globalThis as typeof globalThis & StartupTestGlobals)[
+ '__failPlaylistReads'
+ ] = false;
+ });
+ await startup.getByRole('button', { name: 'Retry', exact: true }).click();
+ await openSources(page);
+ await expect(
+ page.getByText('Current synthetic source', { exact: true })
+ ).toBeVisible();
+ await expect(startup.locator('section')).toHaveCount(0);
}
test.describe('v0.19 profile migration', () => {
+ for (const response of [0, 1]) {
+ test(`keeps current sources usable with a corrupt legacy profile: choice ${response}`, async ({
+ dataDir,
+ }) => {
+ const initial = await launchElectronApp(dataDir);
+ try {
+ await initial.mainWindow.evaluate(() =>
+ window.electron.dbUpsertAppPlaylist({
+ _id: 'current',
+ title: 'Current synthetic source',
+ count: 0,
+ importDate: '2026-01-01',
+ lastUsage: '2026-01-01',
+ autoRefresh: false,
+ })
+ );
+ } finally {
+ await closeElectronApp(initial);
+ }
+ const legacy = join(
+ dataDir,
+ 'electron-backend',
+ 'IndexedDB',
+ 'file__0.indexeddb.leveldb'
+ );
+ await mkdir(legacy, { recursive: true });
+ const marker = join(legacy, 'CURRENT');
+ const corruptManifest = 'Synthetic invalid LevelDB manifest';
+ await writeFile(marker, corruptManifest);
+ const { app, page } = await launchWithRecoveryChoice(
+ dataDir,
+ response
+ );
+ try {
+ await openSources(page);
+ await expect(
+ page.getByText('Current synthetic source', { exact: true })
+ ).toBeVisible();
+ expect(await sourceCount(page)).toBe(1);
+ expect(await readFile(marker, 'utf8')).toBe(corruptManifest);
+ expect(
+ await page.evaluate(
+ (key) => window.electron.dbGetAppState(key),
+ recoveryKey
+ )
+ ).toBe(response === 0 ? 'declined' : null);
+ } finally {
+ await closeElectronApp({ electronApp: app, mainWindow: page });
+ }
+ });
+ }
+
+ for (const fault of ['defer-epg', 'fail-playlist-reads'] as const) {
+ test(`keeps startup actionable after declining recovery: ${fault}`, async ({
+ dataDir,
+ }, testInfo) => {
+ const initial = await launchElectronApp(dataDir);
+ try {
+ await initial.mainWindow.evaluate(() =>
+ window.electron.dbUpsertAppPlaylist({
+ _id: 'current',
+ title: 'Current synthetic source',
+ count: 1,
+ playlist: {
+ header: { raw: '#EXTM3U' },
+ items: [
+ {
+ id: 'synthetic-channel',
+ raw: '#EXTINF:-1,Synthetic channel\nhttps://channel.invalid/live',
+ name: 'Synthetic channel',
+ url: 'https://channel.invalid/live',
+ group: { title: 'Test' },
+ },
+ ],
+ },
+ importDate: '2026-01-01',
+ lastUsage: '2026-01-01',
+ autoRefresh: false,
+ })
+ );
+ } finally {
+ await closeElectronApp(initial);
+ }
+ await seedLegacyProfile(dataDir, 'm3u', false);
+ const { app, page } = await launchWithRecoveryChoice(
+ dataDir,
+ 0,
+ false,
+ false,
+ fault
+ );
+ try {
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (key) => window.electron.dbGetAppState(key),
+ recoveryKey
+ )
+ )
+ .toBe('declined');
+ const startup = page.locator('app-startup-status section');
+ await expect(startup).toHaveCSS('app-region', 'drag');
+ if (fault === 'defer-epg') {
+ await expect(page.locator('#initial-splash')).toHaveCount(
+ 0
+ );
+ await expect(
+ page.locator('app-startup-status').getByRole('status')
+ ).toContainText('Preparing your library');
+ await expect
+ .poll(() =>
+ app.evaluate(
+ () =>
+ typeof (
+ globalThis as typeof globalThis &
+ StartupTestGlobals
+ )['__releaseStartupEpg'] === 'function'
+ )
+ )
+ .toBe(true);
+ for (const theme of ['light', 'dark'] as const) {
+ await applyTheme(page, theme);
+ await page.screenshot({
+ path: testInfo.outputPath(`startup-${theme}.png`),
+ animations: 'disabled',
+ });
+ }
+ await page.setViewportSize({ width: 480, height: 640 });
+ await expect(page.locator('body')).toHaveJSProperty(
+ 'scrollWidth',
+ 480
+ );
+ await page.screenshot({
+ path: testInfo.outputPath('startup-dark-narrow.png'),
+ animations: 'disabled',
+ });
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await app.evaluate(() =>
+ (globalThis as typeof globalThis & StartupTestGlobals)[
+ '__releaseStartupEpg'
+ ]()
+ );
+ } else {
+ await expect(page.getByRole('alert')).toContainText(
+ 'Your sources could not be loaded'
+ );
+ await expect(
+ startup.getByRole('button', {
+ name: 'Retry',
+ exact: true,
+ })
+ ).toHaveCSS('app-region', 'no-drag');
+ await app.evaluate(() => {
+ const hooks = globalThis as typeof globalThis &
+ StartupTestGlobals;
+ hooks.__failPlaylistReads = false;
+ hooks.__deferRecoveredEpg = true;
+ });
+ await page
+ .getByRole('button', { name: 'Retry', exact: true })
+ .click();
+ // Metadata is readable again, but startup must still wait
+ // for the reconciliation that failed during settings load.
+ await expect
+ .poll(() =>
+ app.evaluate(
+ () =>
+ typeof (
+ globalThis as typeof globalThis &
+ StartupTestGlobals
+ ).__releaseStartupEpg
+ )
+ )
+ .toBe('function');
+ await expect(startup).toHaveAttribute('role', 'status');
+ await app.evaluate(() => {
+ const hooks = globalThis as typeof globalThis &
+ StartupTestGlobals;
+ hooks.__deferRecoveredEpg = false;
+ hooks.__releaseStartupEpg();
+ });
+ }
+ await openSources(page);
+ await expect(
+ page.getByText('Current synthetic source', { exact: true })
+ ).toBeVisible();
+ await expect(
+ page.locator('app-startup-status section')
+ ).toHaveCount(0);
+ if (fault === 'fail-playlist-reads') {
+ await verifyBackupReloadRecovery(app, page, dataDir);
+ }
+ } finally {
+ await closeElectronApp({ electronApp: app, mainWindow: page });
+ }
+ });
+ }
+
for (const active of ['xtream-2', 'stalker-60', 'm3u']) {
test(`imports all 65 sources with ${active} last active, offline`, async ({
dataDir,
@@ -298,8 +563,27 @@ test.describe('v0.19 profile migration', () => {
await closeElectronApp(initial);
}
await seedLegacyProfile(dataDir, 'xtream-2', false);
- let recovered = await launchWithRecoveryChoice(dataDir, 0);
+ let recovered = await launchWithRecoveryChoice(dataDir, 0, false, true);
try {
+ // Leave the offer pending until renderer startup has reached it.
+ // A synchronous stub plus raw DB reads cannot detect a blank route
+ // after the real user eventually chooses Keep current sources.
+ await expect
+ .poll(() =>
+ recovered.app.evaluate(
+ () =>
+ typeof (
+ globalThis as typeof globalThis &
+ StartupTestGlobals
+ )['__resolveLegacyRecoveryDialog'] === 'function'
+ )
+ )
+ .toBe(true);
+ await recovered.app.evaluate(() =>
+ (globalThis as typeof globalThis & StartupTestGlobals)[
+ '__resolveLegacyRecoveryDialog'
+ ]()
+ );
await expect
.poll(() =>
recovered.page.evaluate(
@@ -314,6 +598,12 @@ test.describe('v0.19 profile migration', () => {
app.getPath('userData')
)
).toBe(join(dataDir, 'user-data'));
+ await openSources(recovered.page);
+ await expect(
+ recovered.page.getByText('Current edited source', {
+ exact: true,
+ })
+ ).toBeVisible();
} finally {
await closeElectronApp({
electronApp: recovered.app,
diff --git a/apps/web/src/app/app-startup-status.component.spec.ts b/apps/web/src/app/app-startup-status.component.spec.ts
new file mode 100644
index 000000000..dae454396
--- /dev/null
+++ b/apps/web/src/app/app-startup-status.component.spec.ts
@@ -0,0 +1,66 @@
+import { TestBed } from '@angular/core/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { TranslateModule } from '@ngx-translate/core';
+import {
+ PlaylistActions,
+ selectPlaylistsLoadFailed,
+ selectPlaylistsLoadingFlag,
+} from '@iptvnator/m3u-state';
+import { AppStartupStatusComponent } from './app-startup-status.component';
+
+describe('AppStartupStatusComponent', () => {
+ beforeEach(() =>
+ TestBed.configureTestingModule({
+ imports: [AppStartupStatusComponent, TranslateModule.forRoot()],
+ providers: [
+ provideMockStore({
+ selectors: [
+ { selector: selectPlaylistsLoadingFlag, value: false },
+ { selector: selectPlaylistsLoadFailed, value: false },
+ ],
+ }),
+ ],
+ })
+ );
+
+ it('keeps showing preparation while EPG reconciliation delays the route', () => {
+ const store = TestBed.inject(MockStore);
+ store.overrideSelector(selectPlaylistsLoadingFlag, true);
+ const fixture = TestBed.createComponent(AppStartupStatusComponent);
+ fixture.detectChanges();
+ expect(
+ fixture.nativeElement.querySelector('[role="status"]')
+ ).not.toBeNull();
+ fixture.componentRef.setInput('routeReady', true);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('section')).toBeNull();
+ });
+
+ it('does not expose an empty workspace before sources finish loading', () => {
+ const fixture = TestBed.createComponent(AppStartupStatusComponent);
+ fixture.componentRef.setInput('routeReady', true);
+ fixture.detectChanges();
+ expect(fixture.componentInstance.complete()).toBe(false);
+ const store = TestBed.inject(MockStore);
+ store.overrideSelector(selectPlaylistsLoadingFlag, true);
+ store.refreshState();
+ fixture.detectChanges();
+ expect(fixture.componentInstance.complete()).toBe(true);
+ });
+
+ it('offers a retry on failure without rendering raw storage errors', () => {
+ TestBed.inject(MockStore).overrideSelector(
+ selectPlaylistsLoadFailed,
+ true
+ );
+ const fixture = TestBed.createComponent(AppStartupStatusComponent);
+ fixture.detectChanges();
+ expect(
+ fixture.nativeElement.querySelector('[role="alert"]')
+ ).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('mat-spinner')).toBeNull();
+ const dispatch = jest.spyOn(TestBed.inject(MockStore), 'dispatch');
+ fixture.nativeElement.querySelector('button').click();
+ expect(dispatch).toHaveBeenCalledWith(PlaylistActions.loadPlaylists());
+ });
+});
diff --git a/apps/web/src/app/app-startup-status.component.ts b/apps/web/src/app/app-startup-status.component.ts
new file mode 100644
index 000000000..46375638c
--- /dev/null
+++ b/apps/web/src/app/app-startup-status.component.ts
@@ -0,0 +1,140 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+ input,
+} from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { Store } from '@ngrx/store';
+import { TranslatePipe } from '@ngx-translate/core';
+import {
+ PlaylistActions,
+ selectPlaylistsLoadFailed,
+ selectPlaylistsLoadingFlag,
+} from '@iptvnator/m3u-state';
+
+/** The first route also waits for settings and XMLTV source reconciliation. */
+@Component({
+ selector: 'app-startup-status',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [MatButtonModule, MatProgressSpinnerModule, TranslatePipe],
+ template: `
+ @if (!complete()) {
+
+
+ @if (!failed()) {
+
+ }
+
+ {{
+ (failed() ? 'STARTUP.FAILED' : 'STARTUP.PREPARING')
+ | translate
+ }}
+
+
+ {{
+ (failed()
+ ? 'STARTUP.FAILED_DETAIL'
+ : 'STARTUP.PREPARING_DETAIL'
+ ) | translate
+ }}
+
+ @if (failed()) {
+
+ }
+
+ }
+ `,
+ styles: `
+ :host {
+ display: contents;
+ }
+ section {
+ app-region: drag;
+ position: relative;
+ isolation: isolate;
+ overflow: clip;
+ min-height: 100dvh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 48px 24px;
+ box-sizing: border-box;
+ background: var(--app-content-bg);
+ color: var(--app-heading-color);
+ text-align: center;
+ }
+ .startup-watermark {
+ position: absolute;
+ right: -14%;
+ bottom: -34%;
+ width: clamp(420px, 76vw, 960px);
+ height: auto;
+ opacity: 0.07;
+ transform: rotate(-14deg);
+ mask-image: linear-gradient(135deg, transparent 15%, #000 78%);
+ pointer-events: none;
+ z-index: 0;
+ }
+ section > :not(svg) {
+ position: relative;
+ z-index: 1;
+ }
+ h1 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 500;
+ }
+ p {
+ margin: 0;
+ max-width: 420px;
+ color: var(--app-body-color);
+ line-height: 1.5;
+ }
+ button {
+ margin-top: 8px;
+ app-region: no-drag;
+ }
+ `,
+})
+export class AppStartupStatusComponent {
+ private readonly store = inject(Store);
+ readonly routeReady = input(false);
+ private readonly sourcesReady = this.store.selectSignal(
+ selectPlaylistsLoadingFlag
+ );
+ readonly failed = this.store.selectSignal(selectPlaylistsLoadFailed);
+ readonly complete = computed(
+ () => this.routeReady() && this.sourcesReady()
+ );
+
+ retry(): void {
+ this.store.dispatch(PlaylistActions.loadPlaylists());
+ }
+}
diff --git a/apps/web/src/app/app.component.html b/apps/web/src/app/app.component.html
index 81e10494d..64cf70d95 100644
--- a/apps/web/src/app/app.component.html
+++ b/apps/web/src/app/app.component.html
@@ -1,4 +1,7 @@
-
+
+
+
+
diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts
index a80a56979..1ad524812 100644
--- a/apps/web/src/app/app.component.ts
+++ b/apps/web/src/app/app.component.ts
@@ -1,4 +1,11 @@
-import { Component, effect, HostBinding, inject, OnInit } from '@angular/core';
+import {
+ Component,
+ effect,
+ HostBinding,
+ inject,
+ OnInit,
+ signal,
+} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
@@ -34,6 +41,7 @@ import { SettingsService } from './services/settings.service';
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
import { AppUpdateNotificationPanelComponent } from './app-update-notification-panel.component';
+import { AppStartupStatusComponent } from './app-startup-status.component';
const debugAppComponent = createDevLogger('AppComponent');
@@ -41,6 +49,7 @@ const debugAppComponent = createDevLogger('AppComponent');
selector: 'app-root',
templateUrl: './app.component.html',
imports: [
+ AppStartupStatusComponent,
AppUpdateNotificationPanelComponent,
EpgProgressPanelComponent,
RouterOutlet,
@@ -48,6 +57,7 @@ const debugAppComponent = createDevLogger('AppComponent');
],
})
export class AppComponent implements OnInit {
+ readonly routeReady = signal(false);
@HostBinding('class.macos-platform') get isMacOS() {
return this.runtime.isMacOS;
}
diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json
index 7601cbcf0..967dd8425 100644
--- a/apps/web/src/assets/i18n/ar.json
+++ b/apps/web/src/assets/i18n/ar.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "لا توجد بيانات للحساب",
"NO_ACCOUNT_DATA_DESCRIPTION": "لا تنشر هذه البوابة معلومات الحساب لهذا الجهاز."
}
+ },
+ "STARTUP": {
+ "PREPARING": "جارٍ تجهيز المكتبة",
+ "PREPARING_DETAIL": "جارٍ تحميل المصادر والتحقق من دليل البرامج. قد تستغرق المكتبات الكبيرة وقتًا أطول.",
+ "FAILED": "تعذر تحميل المصادر",
+ "FAILED_DETAIL": "أعد محاولة قراءة المصادر المحفوظة. لم تُحذف بياناتك."
}
}
diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json
index 1b8e21c33..cde4f2d64 100644
--- a/apps/web/src/assets/i18n/ary.json
+++ b/apps/web/src/assets/i18n/ary.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "ما كايناش بيانات الحساب",
"NO_ACCOUNT_DATA_DESCRIPTION": "هاد البوابة ما كتعطيش معلومات الحساب لهاد الجهاز."
}
+ },
+ "STARTUP": {
+ "PREPARING": "كنوجدو المكتبة ديالك",
+ "PREPARING_DETAIL": "كنحمّلو المصادر وكنراجعو دليل البرامج. المكتبات الكبيرة تقدر تاخد وقت أكثر.",
+ "FAILED": "ما قدرناش نحمّلو المصادر",
+ "FAILED_DETAIL": "عاود جرّب تقرا المصادر المحفوظة. البيانات ديالك ما تمسحوش."
}
}
diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json
index 54db75978..2e4d9528b 100644
--- a/apps/web/src/assets/i18n/by.json
+++ b/apps/web/src/assets/i18n/by.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Няма даных пра акаўнт",
"NO_ACCOUNT_DATA_DESCRIPTION": "Гэты партал не дае інфармацыі пра акаўнт для гэтай прылады."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Падрыхтоўка бібліятэкі",
+ "PREPARING_DETAIL": "Загружаем крыніцы і правяраем тэлепраграму. Вялікая бібліятэка можа патрабаваць больш часу.",
+ "FAILED": "Не ўдалося загрузіць крыніцы",
+ "FAILED_DETAIL": "Паўтарыце чытанне захаваных крыніц. Вашы даныя не выдалены."
}
}
diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json
index ca612ef99..7fb11035c 100644
--- a/apps/web/src/assets/i18n/de.json
+++ b/apps/web/src/assets/i18n/de.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Keine Kontodaten",
"NO_ACCOUNT_DATA_DESCRIPTION": "Dieses Portal stellt für dieses Gerät keine Kontoinformationen bereit."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Bibliothek wird vorbereitet",
+ "PREPARING_DETAIL": "Quellen werden geladen und der Programmführer geprüft. Große Bibliotheken können etwas länger dauern.",
+ "FAILED": "Quellen konnten nicht geladen werden",
+ "FAILED_DETAIL": "Versuche erneut, die gespeicherten Quellen zu lesen. Deine Daten wurden nicht gelöscht."
}
}
diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json
index ac42fd3d7..dfbf61091 100644
--- a/apps/web/src/assets/i18n/el.json
+++ b/apps/web/src/assets/i18n/el.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Δεν υπάρχουν στοιχεία λογαριασμού",
"NO_ACCOUNT_DATA_DESCRIPTION": "Αυτή η πύλη δεν δημοσιεύει πληροφορίες λογαριασμού για αυτήν τη συσκευή."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Προετοιμασία βιβλιοθήκης",
+ "PREPARING_DETAIL": "Φόρτωση πηγών και έλεγχος του οδηγού προγράμματος. Οι μεγάλες βιβλιοθήκες μπορεί να χρειαστούν περισσότερο χρόνο.",
+ "FAILED": "Δεν ήταν δυνατή η φόρτωση των πηγών",
+ "FAILED_DETAIL": "Δοκιμάστε ξανά την ανάγνωση των αποθηκευμένων πηγών. Τα δεδομένα σας δεν διαγράφηκαν."
}
}
diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json
index cc7ce7ed2..9fe29476e 100644
--- a/apps/web/src/assets/i18n/en.json
+++ b/apps/web/src/assets/i18n/en.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "No account details",
"NO_ACCOUNT_DATA_DESCRIPTION": "This portal does not publish account information for this device."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Preparing your library",
+ "PREPARING_DETAIL": "Loading your sources and checking the programme guide. Large libraries may take a little longer.",
+ "FAILED": "Your sources could not be loaded",
+ "FAILED_DETAIL": "Retry to read your saved sources again. Your data has not been deleted."
}
}
diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json
index 5d4fa0409..fc3c939e8 100644
--- a/apps/web/src/assets/i18n/es.json
+++ b/apps/web/src/assets/i18n/es.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Sin datos de la cuenta",
"NO_ACCOUNT_DATA_DESCRIPTION": "Este portal no publica información de la cuenta para este dispositivo."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Preparando tu biblioteca",
+ "PREPARING_DETAIL": "Cargando fuentes y comprobando la guía de programación. Las bibliotecas grandes pueden tardar un poco más.",
+ "FAILED": "No se pudieron cargar tus fuentes",
+ "FAILED_DETAIL": "Vuelve a intentar leer las fuentes guardadas. Tus datos no se han eliminado."
}
}
diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json
index bef9faadd..8a3ac8fc4 100644
--- a/apps/web/src/assets/i18n/fr.json
+++ b/apps/web/src/assets/i18n/fr.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Aucune donnée de compte",
"NO_ACCOUNT_DATA_DESCRIPTION": "Ce portail ne publie pas d’informations de compte pour cet appareil."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Préparation de la bibliothèque",
+ "PREPARING_DETAIL": "Chargement des sources et vérification du guide TV. Les grandes bibliothèques peuvent prendre plus de temps.",
+ "FAILED": "Impossible de charger vos sources",
+ "FAILED_DETAIL": "Réessayez de lire les sources enregistrées. Vos données n’ont pas été supprimées."
}
}
diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json
index 802970bc2..b48fa91f3 100644
--- a/apps/web/src/assets/i18n/hu.json
+++ b/apps/web/src/assets/i18n/hu.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Nincsenek fiókadatok",
"NO_ACCOUNT_DATA_DESCRIPTION": "Ez a portál nem tesz közzé fiókadatokat ehhez az eszközhöz."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Könyvtár előkészítése",
+ "PREPARING_DETAIL": "Források betöltése és a műsorújság ellenőrzése. Nagy könyvtárak esetén ez tovább tarthat.",
+ "FAILED": "Nem sikerült betölteni a forrásokat",
+ "FAILED_DETAIL": "Próbáld újra beolvasni a mentett forrásokat. Az adataid nem lettek törölve."
}
}
diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json
index 17b624a02..1a1988627 100644
--- a/apps/web/src/assets/i18n/it.json
+++ b/apps/web/src/assets/i18n/it.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Nessun dato account",
"NO_ACCOUNT_DATA_DESCRIPTION": "Questo portale non pubblica informazioni sull’account per questo dispositivo."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Preparazione della libreria",
+ "PREPARING_DETAIL": "Caricamento delle sorgenti e verifica della guida TV. Le librerie grandi possono richiedere più tempo.",
+ "FAILED": "Impossibile caricare le sorgenti",
+ "FAILED_DETAIL": "Riprova a leggere le sorgenti salvate. I tuoi dati non sono stati eliminati."
}
}
diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json
index 6dc6ab3a3..a3c97d7ef 100644
--- a/apps/web/src/assets/i18n/ja.json
+++ b/apps/web/src/assets/i18n/ja.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "アカウント情報なし",
"NO_ACCOUNT_DATA_DESCRIPTION": "このポータルは、このデバイスのアカウント情報を公開していません。"
}
+ },
+ "STARTUP": {
+ "PREPARING": "ライブラリを準備しています",
+ "PREPARING_DETAIL": "ソースを読み込み、番組表を確認しています。大きなライブラリでは時間がかかる場合があります。",
+ "FAILED": "ソースを読み込めませんでした",
+ "FAILED_DETAIL": "保存したソースの読み込みを再試行してください。データは削除されていません。"
}
}
diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json
index 0dc3c63e2..92b2b3187 100644
--- a/apps/web/src/assets/i18n/ko.json
+++ b/apps/web/src/assets/i18n/ko.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "계정 정보 없음",
"NO_ACCOUNT_DATA_DESCRIPTION": "이 포털은 이 기기에 대한 계정 정보를 제공하지 않습니다."
}
+ },
+ "STARTUP": {
+ "PREPARING": "라이브러리 준비 중",
+ "PREPARING_DETAIL": "소스를 불러오고 편성표를 확인하고 있습니다. 라이브러리가 크면 시간이 더 걸릴 수 있습니다.",
+ "FAILED": "소스를 불러오지 못했습니다",
+ "FAILED_DETAIL": "저장된 소스를 다시 읽어 보세요. 데이터는 삭제되지 않았습니다."
}
}
diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json
index 7b8b26776..db1b926a5 100644
--- a/apps/web/src/assets/i18n/nl.json
+++ b/apps/web/src/assets/i18n/nl.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Geen accountgegevens",
"NO_ACCOUNT_DATA_DESCRIPTION": "Dit portaal publiceert geen accountinformatie voor dit apparaat."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Bibliotheek voorbereiden",
+ "PREPARING_DETAIL": "Bronnen laden en de programmagids controleren. Grote bibliotheken kunnen wat meer tijd kosten.",
+ "FAILED": "Je bronnen konden niet worden geladen",
+ "FAILED_DETAIL": "Probeer je opgeslagen bronnen opnieuw te lezen. Je gegevens zijn niet verwijderd."
}
}
diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json
index 03b0db6c8..aa82867ff 100644
--- a/apps/web/src/assets/i18n/pl.json
+++ b/apps/web/src/assets/i18n/pl.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Brak danych konta",
"NO_ACCOUNT_DATA_DESCRIPTION": "Ten portal nie udostępnia informacji o koncie dla tego urządzenia."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Przygotowywanie biblioteki",
+ "PREPARING_DETAIL": "Wczytywanie źródeł i sprawdzanie programu TV. Duże biblioteki mogą wymagać więcej czasu.",
+ "FAILED": "Nie udało się wczytać źródeł",
+ "FAILED_DETAIL": "Spróbuj ponownie odczytać zapisane źródła. Twoje dane nie zostały usunięte."
}
}
diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json
index 9ab426739..052767f10 100644
--- a/apps/web/src/assets/i18n/pt.json
+++ b/apps/web/src/assets/i18n/pt.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Sem dados da conta",
"NO_ACCOUNT_DATA_DESCRIPTION": "Este portal não publica informações da conta para este dispositivo."
}
+ },
+ "STARTUP": {
+ "PREPARING": "A preparar a biblioteca",
+ "PREPARING_DETAIL": "A carregar fontes e verificar o guia de programação. Bibliotecas grandes podem demorar mais.",
+ "FAILED": "Não foi possível carregar as fontes",
+ "FAILED_DETAIL": "Tente ler novamente as fontes guardadas. Os seus dados não foram eliminados."
}
}
diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json
index 24220cc25..2e7ed9474 100644
--- a/apps/web/src/assets/i18n/ru.json
+++ b/apps/web/src/assets/i18n/ru.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Нет данных об аккаунте",
"NO_ACCOUNT_DATA_DESCRIPTION": "Этот портал не предоставляет информацию об аккаунте для этого устройства."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Подготовка библиотеки",
+ "PREPARING_DETAIL": "Загружаем источники и проверяем телепрограмму. Большая библиотека может потребовать больше времени.",
+ "FAILED": "Не удалось загрузить источники",
+ "FAILED_DETAIL": "Повторите чтение сохранённых источников. Ваши данные не удалены."
}
}
diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json
index 638a39f8c..7327e33d4 100644
--- a/apps/web/src/assets/i18n/tr.json
+++ b/apps/web/src/assets/i18n/tr.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Hesap verisi yok",
"NO_ACCOUNT_DATA_DESCRIPTION": "Bu portal, bu cihaz için hesap bilgisi yayınlamıyor."
}
+ },
+ "STARTUP": {
+ "PREPARING": "Kitaplığınız hazırlanıyor",
+ "PREPARING_DETAIL": "Kaynaklar yükleniyor ve program rehberi kontrol ediliyor. Büyük kitaplıklar biraz daha uzun sürebilir.",
+ "FAILED": "Kaynaklarınız yüklenemedi",
+ "FAILED_DETAIL": "Kayıtlı kaynaklarınızı okumayı yeniden deneyin. Verileriniz silinmedi."
}
}
diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json
index 0dd368e2c..aa80b4476 100644
--- a/apps/web/src/assets/i18n/zh.json
+++ b/apps/web/src/assets/i18n/zh.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "无账户数据",
"NO_ACCOUNT_DATA_DESCRIPTION": "该门户不为此设备提供账户信息。"
}
+ },
+ "STARTUP": {
+ "PREPARING": "正在准备媒体库",
+ "PREPARING_DETAIL": "正在加载来源并检查节目指南。较大的媒体库可能需要更长时间。",
+ "FAILED": "无法加载来源",
+ "FAILED_DETAIL": "请重试读取已保存的来源。您的数据未被删除。"
}
}
diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json
index 04bab7f42..a59d01238 100644
--- a/apps/web/src/assets/i18n/zhtw.json
+++ b/apps/web/src/assets/i18n/zhtw.json
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "沒有帳戶資料",
"NO_ACCOUNT_DATA_DESCRIPTION": "此入口網站不提供此裝置的帳戶資訊。"
}
+ },
+ "STARTUP": {
+ "PREPARING": "正在準備媒體庫",
+ "PREPARING_DETAIL": "正在載入來源並檢查節目指南。較大的媒體庫可能需要更長時間。",
+ "FAILED": "無法載入來源",
+ "FAILED_DETAIL": "請重試讀取已儲存的來源。您的資料未被刪除。"
}
}
diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md
index 94c35e3b3..39103f441 100644
--- a/docs/architecture/m3u-playlist-module.md
+++ b/docs/architecture/m3u-playlist-module.md
@@ -76,12 +76,42 @@ migrations. In particular, `idx_content_epg_channel` must follow the addition of
`content.epg_channel_id`; creating it in the initial CREATE TABLE pass aborts
initialization on the v0.19 schema before the playlist migration can run.
+Initial workspace routing waits for settings and XMLTV source reconciliation.
+Removing a large obsolete EPG cache can therefore delay the first view even
+after legacy recovery is declined. `AppStartupStatusComponent` shows preparation
+until both the initial routed component and source inventory are ready; routed
+content stays hidden from pointer and keyboard interaction during that interval.
+There is no estimated percentage or timeout that bypasses reconciliation.
+
+Source inventory reads recreate the storage request for one automatic retry
+after 300 ms. Exhausted retries emit `Load Playlists Failure` without raw error
+details and leave `allPlaylistsLoaded` false. The startup error offers Retry,
+which dispatches a fresh load without restarting or changing saved sources.
+Before publishing a successful inventory, the effect waits for settings loading
+to register initial cleanup, then waits for any active EPG reconciliation and retries the last failed synchronization using its committed,
+normalized global URLs. A settings-read failure never supplies defaults to this
+retry; a newer successful synchronization clears the failure instead of replaying
+an older source set. Repeated cleanup failure leaves Retry available.
+Every inventory load clears readiness, including reloads after backup import,
+so a later read failure also exposes Retry. A successfully loaded empty inventory
+is distinct from a failed read. The preparation/error surface remains a native
+window drag region, with Retry excluded so it stays clickable. Its static,
+monochrome SVG watermark inherits the theme heading color, fades toward the
+content, and is clipped, pointer-transparent and hidden from assistive technology.
+
Validation: `electron-backend-e2e:e2e-ci--src/legacy-playlist-migration.e2e.ts`
seeds the exact v0.19 IndexedDB schema and verbatim SQL CREATE statements with
61 Stalker sources, three Xtream sources, M3U, and linked cached user data.
It exercises source-list UI, alternate last-used sources, retained settings,
recovery consent, existing data, write failure, restart, and deletion after
-migration. Local macOS Electron verification does not substitute for installed
+migration. The refusal case holds a mocked recovery dialog pending until startup
+reaches it, then checks that Sources renders the current source in the same
+launch. Native operating-system dialogs still require separate manual verification.
+The same suite holds EPG reconciliation pending and injects failed metadata reads
+to check preparation, error feedback and recovery through Retry, including a
+second failure after importing a backup into an already loaded session. These use
+controlled IPC faults rather than adding a large, timing-dependent database to CI.
+Local macOS Electron verification does not substitute for installed
Linux Mint MATE, Snap, or Flatpak upgrade testing.
## Module Structure
diff --git a/libs/m3u-state/src/lib/actions.ts b/libs/m3u-state/src/lib/actions.ts
index cbd8c28c2..f7c12b267 100644
--- a/libs/m3u-state/src/lib/actions.ts
+++ b/libs/m3u-state/src/lib/actions.ts
@@ -12,6 +12,7 @@ export const PlaylistActions = createActionGroup({
events: {
'Load Playlists': emptyProps(),
'Load Playlists Success': props<{ playlists: PlaylistMeta[] }>(),
+ 'Load Playlists Failure': emptyProps(),
'Add Playlist': props<{ playlist: Playlist }>(),
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts
index 40ab30ead..5b3e0ed90 100644
--- a/libs/m3u-state/src/lib/effects.ts
+++ b/libs/m3u-state/src/lib/effects.ts
@@ -13,18 +13,23 @@ import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateService } from '@ngx-translate/core';
import {
EMPTY,
+ catchError,
concatMap,
+ defer,
filter,
firstValueFrom,
from,
map,
mergeMap,
+ of,
+ retry,
switchMap,
tap,
withLatestFrom,
} from 'rxjs';
import {
DataService,
+ EpgSourceSettingsService,
PlaylistsService,
SettingsStore,
} from '@iptvnator/services';
@@ -72,6 +77,7 @@ export class PlaylistEffects {
private store = inject(Store);
private translate = inject(TranslateService);
private settingsStore = inject(SettingsStore);
+ private epgSources = inject(EpgSourceSettingsService);
private readonly playlistScopedEpgFetchKeys = new Map();
updateFavorites$ = createEffect(
@@ -257,7 +263,19 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.loadPlaylists),
switchMap(() =>
- this.playlistsService.getAllPlaylists().pipe(
+ defer(() => this.playlistsService.getAllPlaylists()).pipe(
+ // Recreate the storage request once for transient failures.
+ // A final failure is state, not an empty source inventory.
+ retry({ count: 1, delay: 300 }),
+ switchMap((playlists) =>
+ defer(async () => {
+ // Settings can register initial cleanup after the
+ // faster inventory read has already completed.
+ await this.settingsStore.loadSettings();
+ await this.epgSources.retryFailedReconciliation();
+ return playlists;
+ })
+ ),
tap((playlists) => {
this.fetchPlaylistScopedEpgForPlaylists(playlists);
}),
@@ -265,7 +283,8 @@ export class PlaylistEffects {
PlaylistActions.loadPlaylistsSuccess({
playlists,
})
- )
+ ),
+ catchError(() => of(PlaylistActions.loadPlaylistsFailure()))
)
)
);
diff --git a/libs/m3u-state/src/lib/load-playlists.effect.spec.ts b/libs/m3u-state/src/lib/load-playlists.effect.spec.ts
new file mode 100644
index 000000000..5e1959077
--- /dev/null
+++ b/libs/m3u-state/src/lib/load-playlists.effect.spec.ts
@@ -0,0 +1,174 @@
+import { Injector, runInInjectionContext } from '@angular/core';
+import { Router } from '@angular/router';
+import { Actions } from '@ngrx/effects';
+import { Store } from '@ngrx/store';
+import { EpgService } from '@iptvnator/epg/data-access';
+import {
+ EpgSourceSettingsService,
+ PlaylistsService,
+ SettingsStore,
+} from '@iptvnator/services';
+import { EMPTY, of, Subject, throwError } from 'rxjs';
+import { PlaylistActions } from './actions';
+import { PlaylistEffects } from './effects';
+
+// Each subscription must create a fresh storage request, not replay the same
+// rejected promise. Exhausted retries must leave the action stream usable.
+describe('PlaylistEffects loadPlaylists$', () => {
+ let actions$: Subject;
+ let getAllPlaylists: jest.Mock;
+ let effects: PlaylistEffects;
+ let retryFailedReconciliation: jest.Mock;
+ let loadSettings: jest.Mock;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ actions$ = new Subject();
+ getAllPlaylists = jest.fn(() => of([]));
+ retryFailedReconciliation = jest.fn().mockResolvedValue(undefined);
+ loadSettings = jest.fn().mockResolvedValue(undefined);
+ const injector = Injector.create({
+ parent: { get: () => ({}) } as unknown as Injector,
+ providers: [
+ { provide: Actions, useValue: new Actions(actions$) },
+ { provide: Store, useValue: { select: () => EMPTY } },
+ { provide: PlaylistsService, useValue: { getAllPlaylists } },
+ { provide: EpgService, useValue: { fetchEpg: jest.fn() } },
+ { provide: Router, useValue: {} },
+ {
+ provide: EpgSourceSettingsService,
+ useValue: { retryFailedReconciliation },
+ },
+ {
+ provide: SettingsStore,
+ useValue: {
+ loadSettings,
+ getSettings: () => ({ epgUrl: [] }),
+ },
+ },
+ ],
+ });
+ effects = runInInjectionContext(injector, () => new PlaylistEffects());
+ });
+
+ afterEach(() => jest.useRealTimers());
+
+ it('waits for slow settings to register initial cleanup before publishing inventory', async () => {
+ let finishSettings!: () => void;
+ loadSettings.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishSettings = resolve;
+ })
+ );
+ const results: unknown[] = [];
+ const subscription = effects.loadPlaylists$.subscribe((value) =>
+ results.push(value)
+ );
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(getAllPlaylists).toHaveBeenCalledTimes(1);
+ expect(results).toEqual([]);
+ expect(retryFailedReconciliation).not.toHaveBeenCalled();
+ // SettingsStore completes after swallowing the first cleanup failure.
+ retryFailedReconciliation.mockImplementationOnce(() =>
+ Promise.reject(new Error('cleanup still unavailable'))
+ );
+ finishSettings();
+ await jest.runAllTimersAsync();
+ expect(results).toEqual([PlaylistActions.loadPlaylistsFailure()]);
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(results.at(-1)).toEqual(
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ subscription.unsubscribe();
+ });
+
+ it('waits for failed EPG reconciliation to recover before exposing sources', async () => {
+ let finish!: () => void;
+ retryFailedReconciliation.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve;
+ })
+ );
+ const results: unknown[] = [];
+ const subscription = effects.loadPlaylists$.subscribe((value) =>
+ results.push(value)
+ );
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(results).toEqual([]);
+ finish();
+ await jest.runAllTimersAsync();
+ expect(results).toEqual([
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] }),
+ ]);
+ subscription.unsubscribe();
+ });
+
+ it('keeps a failed EPG recovery actionable and retries it on the next load', async () => {
+ retryFailedReconciliation.mockImplementationOnce(() =>
+ Promise.reject(new Error('cleanup failed'))
+ );
+ const results: unknown[] = [];
+ const subscription = effects.loadPlaylists$.subscribe((value) =>
+ results.push(value)
+ );
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(results).toEqual([PlaylistActions.loadPlaylistsFailure()]);
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(results.at(-1)).toEqual(
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ expect(retryFailedReconciliation).toHaveBeenCalledTimes(2);
+ subscription.unsubscribe();
+ });
+
+ it('recovers a transient read error without restarting', async () => {
+ getAllPlaylists.mockReturnValueOnce(
+ throwError(() => new Error('temporary'))
+ );
+ const results: unknown[] = [];
+ const errors: unknown[] = [];
+ const subscription = effects.loadPlaylists$.subscribe({
+ next: (value) => results.push(value),
+ error: (error) => errors.push(error),
+ });
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(errors).toEqual([]);
+ expect(getAllPlaylists).toHaveBeenCalledTimes(2);
+ expect(results).toEqual([
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] }),
+ ]);
+ subscription.unsubscribe();
+ });
+
+ it('reports persistent failure without treating it as an empty library and accepts Retry', async () => {
+ getAllPlaylists.mockReturnValue(
+ throwError(() => new Error('private storage error'))
+ );
+ const results: unknown[] = [];
+ const errors: unknown[] = [];
+ const subscription = effects.loadPlaylists$.subscribe({
+ next: (value) => results.push(value),
+ error: (error) => errors.push(error),
+ });
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(errors).toEqual([]);
+ expect(getAllPlaylists).toHaveBeenCalledTimes(2);
+ expect(results).toEqual([
+ { type: '[Playlists] Load Playlists Failure' },
+ ]);
+ getAllPlaylists.mockReturnValue(of([]));
+ actions$.next(PlaylistActions.loadPlaylists());
+ await jest.runAllTimersAsync();
+ expect(results.at(-1)).toEqual(
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ subscription.unsubscribe();
+ });
+});
diff --git a/libs/m3u-state/src/lib/playlists.state.ts b/libs/m3u-state/src/lib/playlists.state.ts
index 2b4e48568..4a46a04ad 100644
--- a/libs/m3u-state/src/lib/playlists.state.ts
+++ b/libs/m3u-state/src/lib/playlists.state.ts
@@ -9,6 +9,7 @@ export const playlistsAdapter: EntityAdapter =
export interface PlaylistMetaState extends EntityState {
selectedId: string;
allPlaylistsLoaded: boolean;
+ loadFailed: boolean;
selectedFilters: string[];
}
@@ -16,6 +17,7 @@ export const initialPlaylistMetaState: PlaylistMetaState =
playlistsAdapter.getInitialState({
selectedId: '',
allPlaylistsLoaded: false,
+ loadFailed: false,
selectedFilters: ['m3u', 'xtream', 'stalker'],
});
diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts
index da5469cc8..03cc0610a 100644
--- a/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts
+++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.spec.ts
@@ -8,6 +8,46 @@ import { Channel, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
const reducer = createReducer(initialState, ...playlistReducers);
describe('playlistReducers', () => {
+ it('keeps a failed initial inventory unready until a retry succeeds', () => {
+ const failed = reducer(
+ initialState,
+ PlaylistActions.loadPlaylistsFailure()
+ );
+ expect(failed.playlists.loadFailed).toBe(true);
+ expect(failed.playlists.allPlaylistsLoaded).toBe(false);
+ const retrying = reducer(failed, PlaylistActions.loadPlaylists());
+ expect(retrying.playlists.loadFailed).toBe(false);
+ expect(retrying.playlists.allPlaylistsLoaded).toBe(false);
+ const recovered = reducer(
+ retrying,
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ expect(recovered.playlists.loadFailed).toBe(false);
+ expect(recovered.playlists.allPlaylistsLoaded).toBe(true);
+ });
+
+ it('clears readiness when a backup replaces an already loaded inventory', () => {
+ const loaded = reducer(
+ initialState,
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ const emptied = reducer(loaded, PlaylistActions.removeAllPlaylists());
+ const loading = reducer(emptied, PlaylistActions.loadPlaylists());
+ expect(loading.playlists.allPlaylistsLoaded).toBe(false);
+ const failed = reducer(loading, PlaylistActions.loadPlaylistsFailure());
+ expect(failed.playlists.allPlaylistsLoaded).toBe(false);
+ expect(failed.playlists.loadFailed).toBe(true);
+ const retrying = reducer(failed, PlaylistActions.loadPlaylists());
+ expect(retrying.playlists.allPlaylistsLoaded).toBe(false);
+ expect(retrying.playlists.loadFailed).toBe(false);
+ const recovered = reducer(
+ retrying,
+ PlaylistActions.loadPlaylistsSuccess({ playlists: [] })
+ );
+ expect(recovered.playlists.allPlaylistsLoaded).toBe(true);
+ expect(recovered.playlists.loadFailed).toBe(false);
+ });
+
it('persists updateDate and hiddenGroupTitles when playlist meta is updated', () => {
const existingPlaylist: PlaylistMeta = {
_id: 'playlist-1',
diff --git a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts
index 0169c11e4..e3bd19ead 100644
--- a/libs/m3u-state/src/lib/reducers/playlist.reducers.ts
+++ b/libs/m3u-state/src/lib/reducers/playlist.reducers.ts
@@ -6,12 +6,25 @@ import { playlistsAdapter } from '../playlists.state';
import { PlaylistState } from '../state';
export const playlistReducers = [
+ on(PlaylistActions.loadPlaylists, (state): PlaylistState => ({
+ ...state,
+ playlists: {
+ ...state.playlists,
+ allPlaylistsLoaded: false,
+ loadFailed: false,
+ },
+ })),
+ on(PlaylistActions.loadPlaylistsFailure, (state): PlaylistState => ({
+ ...state,
+ playlists: { ...state.playlists, loadFailed: true },
+ })),
on(PlaylistActions.loadPlaylistsSuccess, (state, action): PlaylistState => {
return {
...state,
playlists: playlistsAdapter.addMany(action.playlists, {
...state.playlists,
allPlaylistsLoaded: true,
+ loadFailed: false,
}),
};
}),
diff --git a/libs/m3u-state/src/lib/selectors.ts b/libs/m3u-state/src/lib/selectors.ts
index 0aad2b321..be8bdaec9 100644
--- a/libs/m3u-state/src/lib/selectors.ts
+++ b/libs/m3u-state/src/lib/selectors.ts
@@ -53,6 +53,11 @@ export const selectPlaylistsMetaState = createSelector(
fromPlaylistState.selectPlaylists
);
+export const selectPlaylistsLoadFailed = createSelector(
+ selectPlaylistsMetaState,
+ (state) => state.loadFailed === true
+);
+
export const selectAllPlaylistsMeta = createSelector(
selectPlaylistsMetaState,
fromPlaylistMetaState.getAllPlaylistsMeta
diff --git a/libs/services/src/lib/epg-source-settings.service.spec.ts b/libs/services/src/lib/epg-source-settings.service.spec.ts
index 0bd81c73c..08bebc9d6 100644
--- a/libs/services/src/lib/epg-source-settings.service.spec.ts
+++ b/libs/services/src/lib/epg-source-settings.service.spec.ts
@@ -1,5 +1,5 @@
import { Injector } from '@angular/core';
-import { firstValueFrom, of, Subject } from 'rxjs';
+import { firstValueFrom, of, Subject, throwError } from 'rxjs';
import { EpgSourceSettingsService } from './epg-source-settings.service';
import { PlaylistsService } from './playlists.service';
@@ -109,4 +109,70 @@ describe('EPG source settings synchronization', () => {
]);
expect(service.retainCurrentSources(['a', 'b'], 0)).toEqual(['b']);
});
+ it('retries failed inventory ownership before pruning, using the committed URLs', async () => {
+ const getAllPlaylists = jest
+ .fn()
+ .mockReturnValue(throwError(() => new Error('disk')));
+ const reconcileEpgSources = jest
+ .fn()
+ .mockResolvedValue({ success: true });
+ window.electron = {
+ reconcileEpgSources,
+ } as unknown as typeof window.electron;
+ const injector = Injector.create({
+ providers: [
+ EpgSourceSettingsService,
+ { provide: PlaylistsService, useValue: { getAllPlaylists } },
+ ],
+ });
+ const service = injector.get(EpgSourceSettingsService);
+ // No known committed settings means no permission to prune defaults.
+ await service.retryFailedReconciliation();
+ expect(getAllPlaylists).not.toHaveBeenCalled();
+ await expect(
+ service.synchronize([' current ', 'current'])
+ ).rejects.toThrow();
+ expect(reconcileEpgSources).not.toHaveBeenCalled();
+ getAllPlaylists.mockReturnValue(of([]));
+ await service.retryFailedReconciliation();
+ expect(reconcileEpgSources).toHaveBeenCalledWith(['current']);
+ await service.retryFailedReconciliation();
+ expect(reconcileEpgSources).toHaveBeenCalledTimes(1);
+ });
+
+ it('waits for a newer committed synchronization without replaying an older failed set', async () => {
+ let finish!: (result: { success: boolean }) => void;
+ const reconcileEpgSources = jest
+ .fn()
+ .mockResolvedValueOnce({ success: false })
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ })
+ );
+ window.electron = {
+ reconcileEpgSources,
+ } as unknown as typeof window.electron;
+ const injector = Injector.create({
+ providers: [
+ EpgSourceSettingsService,
+ {
+ provide: PlaylistsService,
+ useValue: { getAllPlaylists: () => of([]) },
+ },
+ ],
+ });
+ const service = injector.get(EpgSourceSettingsService);
+ await expect(service.synchronize(['old'])).rejects.toThrow();
+ const recovery = service.retryFailedReconciliation();
+ const latest = service.synchronize(['new']);
+ await Promise.resolve();
+ finish({ success: true });
+ await Promise.all([latest, recovery]);
+ expect(reconcileEpgSources.mock.calls).toEqual([[['old']], [['new']]]);
+ expect(service.retainCurrentSources(['old', 'new'], 0)).toEqual([
+ 'new',
+ ]);
+ });
});
diff --git a/libs/services/src/lib/epg-source-settings.service.ts b/libs/services/src/lib/epg-source-settings.service.ts
index 129d84789..012ed7cdd 100644
--- a/libs/services/src/lib/epg-source-settings.service.ts
+++ b/libs/services/src/lib/epg-source-settings.service.ts
@@ -42,6 +42,7 @@ export class EpgSourceSettingsService {
private readonly injector = inject(Injector);
private activeUrls = new Set();
private reconciliation: Promise | undefined;
+ private failedReconciliation: { urls: string[] } | undefined;
readonly revision = signal(0);
readonly changed$ = new Subject();
@@ -66,6 +67,18 @@ export class EpgSourceSettingsService {
}
}
+ /** A recovered inventory must finish any previously authorized cleanup. */
+ async retryFailedReconciliation(): Promise {
+ // A save can start between the wait resolving and this continuation.
+ // Recheck ownership before scheduling a retry of the failed URL set.
+ do {
+ await this.waitForReconciliation();
+ } while (this.reconciliation);
+ if (this.failedReconciliation) {
+ await this.synchronize(this.failedReconciliation.urls);
+ }
+ }
+
async synchronize(urls: string[] | string | undefined): Promise {
if (
typeof window === 'undefined' ||
@@ -108,7 +121,9 @@ export class EpgSourceSettingsService {
await window.electron.reconcileEpgSources(normalized);
if (!result.success)
throw new Error('EPG source reconciliation failed');
+ this.failedReconciliation = undefined;
} catch {
+ this.failedReconciliation = { urls: normalized };
throw new EpgSourceReconciliationError();
} finally {
this.revision.update((revision) => revision + 1);
From 186497bf42c28df6a1ef100b4338e60206f60698 Mon Sep 17 00:00:00 2001
From: 4gray
Date: Tue, 8 Sep 2026 07:28:33 +0200
Subject: [PATCH 2/3] feat(website): add the EPG troubleshooting post, fix the
mock identity check
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New post at /blog/epg-wrong-program-fix/: a table separating the three
symptoms (an empty row, a channel showing another station's schedule, and
every programme shifted by a fixed amount), how the tvg-id → tvg-name → name
lookup chain produces the first two, the manual "Map EPG channel" flow, and
the display-only EPG time offset with the sign to use. Seven FAQ entries.
The three screenshots are mock-backed. The Xtream mock gains /demo/guide.xml,
an XMLTV guide for its marketing live channels whose ids deliberately match no
playlist tvg-id, which is what makes the manual mapping worth showing; the
capture imports it through the settings, accepting the private-network
confirmation a loopback source raises, then right-clicks a channel of the M3U
fixture and searches the dialog. The new actions live in
capture-navigation-epg-actions.ts, alongside the setup, portal and download
modules, and borrow one entry point from each of its two neighbours instead of
duplicating their navigation.
Also fixes assertMockServerIdentity: it checked both expected categories
against get_vod_categories, but "Urban Drama" is a series category, so the
reuse path rejected every already-running mock and a capture could only run
when the port happened to be free. Each category is now checked against its
own endpoint.
Co-Authored-By: Claude Opus 5
---
apps/website/README.md | 7 +-
.../screenshots/guide-epg-map-dialog-dark.png | Bin 0 -> 263703 bytes
.../guide-epg-map-dialog-light.png | Bin 0 -> 265764 bytes
.../screenshots/guide-epg-map-menu-dark.png | Bin 0 -> 127148 bytes
.../screenshots/guide-epg-map-menu-light.png | Bin 0 -> 126562 bytes
.../screenshots/guide-epg-offset-dark.png | Bin 0 -> 239168 bytes
.../screenshots/guide-epg-offset-light.png | Bin 0 -> 228454 bytes
apps/website/public/llms.txt | 1 +
.../content/blog/epg-wrong-program-fix.mdx | 145 ++++++++++++++++++
apps/xtream-mock-server/README.md | 1 +
apps/xtream-mock-server/src/app/demo-xmltv.ts | 72 +++++++++
.../xtream-mock-server/src/app/server.spec.ts | 20 +++
apps/xtream-mock-server/src/app/server.ts | 13 ++
tools/release/capture-app-driver.ts | 53 ++++---
tools/release/capture-fixtures.ts | 2 +
.../release/capture-navigation-epg-actions.ts | 121 +++++++++++++++
.../capture-navigation-portal-actions.ts | 2 +-
.../capture-navigation-setup-actions.ts | 27 ++--
tools/release/capture-navigation.ts | 5 +-
tools/release/screenshot-guards.mjs | 4 +
tools/release/screenshots.manifest.json | 24 +++
tools/testing/website-guides.test.mjs | 8 +
22 files changed, 472 insertions(+), 33 deletions(-)
create mode 100644 apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-dark.png
create mode 100644 apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-light.png
create mode 100644 apps/website/public/blog/guides/screenshots/guide-epg-map-menu-dark.png
create mode 100644 apps/website/public/blog/guides/screenshots/guide-epg-map-menu-light.png
create mode 100644 apps/website/public/blog/guides/screenshots/guide-epg-offset-dark.png
create mode 100644 apps/website/public/blog/guides/screenshots/guide-epg-offset-light.png
create mode 100644 apps/website/src/content/blog/epg-wrong-program-fix.mdx
create mode 100644 apps/xtream-mock-server/src/app/demo-xmltv.ts
create mode 100644 tools/release/capture-navigation-epg-actions.ts
diff --git a/apps/website/README.md b/apps/website/README.md
index 95701aa20..1470427fd 100644
--- a/apps/website/README.md
+++ b/apps/website/README.md
@@ -82,7 +82,8 @@ skip shows up in your output.
Evergreen how-to posts live in the blog collection next to release notes
(`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx`,
`m3u-playlist-epg-setup-guide.mdx`, `offline-downloads-guide.mdx`,
-`alternative-sources-guide.mdx` and `remote-control-guide.mdx` in
+`alternative-sources-guide.mdx`, `remote-control-guide.mdx` and
+`epg-wrong-program-fix.mdx` in
`apps/website/src/content/blog/`). Three conventions set them apart:
- **`ContentDisclaimer`.** Every guide opens with
@@ -119,6 +120,10 @@ Evergreen how-to posts live in the blog collection next to release notes
`tools/release/capture-release-screenshots.ts`). Its setup selects a live
channel and saves the remote-control setting, so the app's own server answers
on port 8765 for the duration of the run.
+ The EPG-mapping shots import the mock's XMLTV guide (`/demo/guide.xml`,
+ channel ids that match no playlist `tvg-id` on purpose) into the isolated
+ database through the settings, then right-click a channel of the M3U
+ fixture and search the dialog.
`tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks
each guide for the FAQPage schema, a link to the download hub and the presence
diff --git a/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-dark.png b/apps/website/public/blog/guides/screenshots/guide-epg-map-dialog-dark.png
new file mode 100644
index 0000000000000000000000000000000000000000..eb09ddd5bf6510721e710d53fbde210cf0b41a14
GIT binary patch
literal 263703
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