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