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

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