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
This commit is contained in:
4gray authored and GitHub committed 2026-09-07 21:24:34 +02:00
1 parent 97b0264dee
commit 9f950a1530
37 files changed
+1024 -14

No files matched your search

+6
View File
@@ -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.
+8
View File
@@ -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`
+8
View File
@@ -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)
@@ -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,
@@ -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());
});
});
@@ -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()) {
<section
[attr.role]="failed() ? 'alert' : 'status'"
aria-live="polite"
>
<svg
class="startup-watermark"
viewBox="0 0 256 256"
aria-hidden="true"
focusable="false"
>
<g fill="none" stroke="currentColor" stroke-width="5">
<rect x="46" y="53" width="166" height="115" rx="2" />
<path d="M128 168v32M67 200h122" />
<path
d="M94 101Q129 72 165 101M101 109Q129 85 158 109M108 117Q129 99 151 117"
/>
</g>
<path
d="M119 126Q129 120 139 126L129 138Z"
fill="currentColor"
/>
</svg>
@if (!failed()) {
<mat-spinner diameter="32" aria-hidden="true" />
}
<h1>
{{
(failed() ? 'STARTUP.FAILED' : 'STARTUP.PREPARING')
| translate
}}
</h1>
<p>
{{
(failed()
? 'STARTUP.FAILED_DETAIL'
: 'STARTUP.PREPARING_DETAIL'
) | translate
}}
</p>
@if (failed()) {
<button mat-flat-button type="button" (click)="retry()">
{{ 'RETRY' | translate }}
</button>
}
</section>
}
`,
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());
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
<router-outlet />
<app-startup-status #startup [routeReady]="routeReady()" />
<div [style.display]="startup.complete() ? 'contents' : 'none'">
<router-outlet (activate)="routeReady.set(true)" />
</div>
<app-epg-progress-panel />
+11 -1
View File
@@ -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;
}
+6
View File
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "لا توجد بيانات للحساب",
"NO_ACCOUNT_DATA_DESCRIPTION": "لا تنشر هذه البوابة معلومات الحساب لهذا الجهاز."
}
},
"STARTUP": {
"PREPARING": "جارٍ تجهيز المكتبة",
"PREPARING_DETAIL": "جارٍ تحميل المصادر والتحقق من دليل البرامج. قد تستغرق المكتبات الكبيرة وقتًا أطول.",
"FAILED": "تعذر تحميل المصادر",
"FAILED_DETAIL": "أعد محاولة قراءة المصادر المحفوظة. لم تُحذف بياناتك."
}
}
+6
View File
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "ما كايناش بيانات الحساب",
"NO_ACCOUNT_DATA_DESCRIPTION": "هاد البوابة ما كتعطيش معلومات الحساب لهاد الجهاز."
}
},
"STARTUP": {
"PREPARING": "كنوجدو المكتبة ديالك",
"PREPARING_DETAIL": "كنحمّلو المصادر وكنراجعو دليل البرامج. المكتبات الكبيرة تقدر تاخد وقت أكثر.",
"FAILED": "ما قدرناش نحمّلو المصادر",
"FAILED_DETAIL": "عاود جرّب تقرا المصادر المحفوظة. البيانات ديالك ما تمسحوش."
}
}
+6
View File
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Няма даных пра акаўнт",
"NO_ACCOUNT_DATA_DESCRIPTION": "Гэты партал не дае інфармацыі пра акаўнт для гэтай прылады."
}
},
"STARTUP": {
"PREPARING": "Падрыхтоўка бібліятэкі",
"PREPARING_DETAIL": "Загружаем крыніцы і правяраем тэлепраграму. Вялікая бібліятэка можа патрабаваць больш часу.",
"FAILED": "Не ўдалося загрузіць крыніцы",
"FAILED_DETAIL": "Паўтарыце чытанне захаваных крыніц. Вашы даныя не выдалены."
}
}
+6
View File
@@ -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."
}
}
+6
View File
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Δεν υπάρχουν στοιχεία λογαριασμού",
"NO_ACCOUNT_DATA_DESCRIPTION": "Αυτή η πύλη δεν δημοσιεύει πληροφορίες λογαριασμού για αυτήν τη συσκευή."
}
},
"STARTUP": {
"PREPARING": "Προετοιμασία βιβλιοθήκης",
"PREPARING_DETAIL": "Φόρτωση πηγών και έλεγχος του οδηγού προγράμματος. Οι μεγάλες βιβλιοθήκες μπορεί να χρειαστούν περισσότερο χρόνο.",
"FAILED": "Δεν ήταν δυνατή η φόρτωση των πηγών",
"FAILED_DETAIL": "Δοκιμάστε ξανά την ανάγνωση των αποθηκευμένων πηγών. Τα δεδομένα σας δεν διαγράφηκαν."
}
}
+6
View File
@@ -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."
}
}
+6
View File
@@ -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."
}
}
+6
View File
@@ -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."
}
}
+6
View File
@@ -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."
}
}
+6
View File
@@ -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."
}
}
+6
View File
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "アカウント情報なし",
"NO_ACCOUNT_DATA_DESCRIPTION": "このポータルは、このデバイスのアカウント情報を公開していません。"
}
},
"STARTUP": {
"PREPARING": "ライブラリを準備しています",
"PREPARING_DETAIL": "ソースを読み込み、番組表を確認しています。大きなライブラリでは時間がかかる場合があります。",
"FAILED": "ソースを読み込めませんでした",
"FAILED_DETAIL": "保存したソースの読み込みを再試行してください。データは削除されていません。"
}
}
+6
View File
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "계정 정보 없음",
"NO_ACCOUNT_DATA_DESCRIPTION": "이 포털은 이 기기에 대한 계정 정보를 제공하지 않습니다."
}
},
"STARTUP": {
"PREPARING": "라이브러리 준비 중",
"PREPARING_DETAIL": "소스를 불러오고 편성표를 확인하고 있습니다. 라이브러리가 크면 시간이 더 걸릴 수 있습니다.",
"FAILED": "소스를 불러오지 못했습니다",
"FAILED_DETAIL": "저장된 소스를 다시 읽어 보세요. 데이터는 삭제되지 않았습니다."
}
}
+6
View File
@@ -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."
}
}
+6
View File
@@ -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."
}
}
+6
View File
@@ -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."
}
}
+6
View File
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "Нет данных об аккаунте",
"NO_ACCOUNT_DATA_DESCRIPTION": "Этот портал не предоставляет информацию об аккаунте для этого устройства."
}
},
"STARTUP": {
"PREPARING": "Подготовка библиотеки",
"PREPARING_DETAIL": "Загружаем источники и проверяем телепрограмму. Большая библиотека может потребовать больше времени.",
"FAILED": "Не удалось загрузить источники",
"FAILED_DETAIL": "Повторите чтение сохранённых источников. Ваши данные не удалены."
}
}
+6
View File
@@ -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."
}
}
+6
View File
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "无账户数据",
"NO_ACCOUNT_DATA_DESCRIPTION": "该门户不为此设备提供账户信息。"
}
},
"STARTUP": {
"PREPARING": "正在准备媒体库",
"PREPARING_DETAIL": "正在加载来源并检查节目指南。较大的媒体库可能需要更长时间。",
"FAILED": "无法加载来源",
"FAILED_DETAIL": "请重试读取已保存的来源。您的数据未被删除。"
}
}
+6
View File
@@ -1718,5 +1718,11 @@
"NO_ACCOUNT_DATA_TITLE": "沒有帳戶資料",
"NO_ACCOUNT_DATA_DESCRIPTION": "此入口網站不提供此裝置的帳戶資訊。"
}
},
"STARTUP": {
"PREPARING": "正在準備媒體庫",
"PREPARING_DETAIL": "正在載入來源並檢查節目指南。較大的媒體庫可能需要更長時間。",
"FAILED": "無法載入來源",
"FAILED_DETAIL": "請重試讀取已儲存的來源。您的資料未被刪除。"
}
}
+31 -1
View File
@@ -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
+1
View File
@@ -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 }>(),
+21 -2
View File
@@ -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<string, string>();
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()))
)
)
);
@@ -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<unknown>;
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<void>((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<void>((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();
});
});
@@ -9,6 +9,7 @@ export const playlistsAdapter: EntityAdapter<PlaylistMeta> =
export interface PlaylistMetaState extends EntityState<PlaylistMeta> {
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'],
});
@@ -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',
@@ -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,
}),
};
}),
+5
View File
@@ -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
@@ -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',
]);
});
});
@@ -42,6 +42,7 @@ export class EpgSourceSettingsService {
private readonly injector = inject(Injector);
private activeUrls = new Set<string>();
private reconciliation: Promise<void> | undefined;
private failedReconciliation: { urls: string[] } | undefined;
readonly revision = signal(0);
readonly changed$ = new Subject<void>();
@@ -66,6 +67,18 @@ export class EpgSourceSettingsService {
}
}
/** A recovered inventory must finish any previously authorized cleanup. */
async retryFailedReconciliation(): Promise<void> {
// 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<void> {
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);