mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
97b0264dee
commit
9f950a1530
37 files changed
+1024
-14
No files matched your search
@@ -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 }>(),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user