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);