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] 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()) { +
+ } + `, + 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);