From a0a084fef7f7114cd0f95afdb39e98379ddc56b0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 20:42:22 +0200 Subject: [PATCH] feat: enhance content loading state management and improve recent playlists component tests --- .../recent-playlists.component.spec.ts | 36 ++++++ .../recent-playlists.component.ts | 22 ++-- .../features/with-content.feature.spec.ts | 115 ++++++++++++++++++ .../stores/features/with-content.feature.ts | 67 +++++++++- .../stores/features/with-selection.feature.ts | 36 +++++- .../data-access/src/lib/xtream-state.ts | 6 + 6 files changed, 262 insertions(+), 20 deletions(-) diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index aa35f1b04..b62643b34 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -176,6 +176,11 @@ describe('RecentPlaylistsComponent busy state', () => { jest.spyOn(store, 'dispatch'); }); + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + it('tracks delete progress and clears the busy row after completion', async () => { const item = createPlaylistMeta({ _id: 'playlist-delete-1' }); const deletion = createDeferred(); @@ -291,15 +296,31 @@ describe('RecentPlaylistsComponent busy state', () => { it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => { const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' }); let confirmPromise: Promise | undefined; + const executionOrder: string[] = []; const dateNowSpy = jest .spyOn(Date, 'now') .mockReturnValue(1712145600000); + const originalSetItem = Storage.prototype.setItem; + const setItemSpy = jest + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(function (key: string, value: string) { + executionOrder.push('setItem'); + return originalSetItem.call(this, key, value); + }); dialogService.openConfirmDialog.mockImplementation( ({ onConfirm }: { onConfirm?: () => Promise }) => { confirmPromise = onConfirm?.(); } ); + (store.dispatch as jest.Mock).mockImplementation((action: unknown) => { + executionOrder.push('dispatch'); + return action; + }); + router.navigate.mockImplementation((commands: unknown[]) => { + executionOrder.push('navigate'); + return Promise.resolve(Boolean(commands)); + }); databaseService.deleteXtreamPlaylistContent.mockResolvedValue({ success: true, favoritedXtreamIds: [101, 202], @@ -326,12 +347,27 @@ describe('RecentPlaylistsComponent busy state', () => { playlist: { ...item, updateDate: 1712145600000 }, }) ); + expect(setItemSpy).toHaveBeenCalledWith( + `xtream-restore-${item._id}`, + JSON.stringify({ + favoritedXtreamIds: [101, 202], + recentlyViewedXtreamIds: [ + { + xtreamId: 303, + viewedAt: '2026-04-03T11:15:00.000Z', + }, + ], + hiddenCategories: [{ xtreamId: 404, type: 'live' }], + }) + ); expect(router.navigate).toHaveBeenCalledWith([ '/workspace', 'xtreams', item._id, ]); + expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']); + setItemSpy.mockRestore(); dateNowSpy.mockRestore(); }); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index a5d914213..bd13efd1c 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -368,6 +368,17 @@ export class RecentPlaylistsComponent { }), ]); + const restoreKey = `xtream-restore-${item._id}`; + const restorePayload = { + favoritedXtreamIds, + recentlyViewedXtreamIds, + hiddenCategories, + }; + localStorage.setItem( + restoreKey, + JSON.stringify(restorePayload) + ); + // Update the timestamp in NgRx / IndexedDB this.store.dispatch( PlaylistActions.updatePlaylistMeta({ @@ -375,17 +386,6 @@ export class RecentPlaylistsComponent { }) ); - // Persist user data so it can be restored after re-import - const restoreKey = `xtream-restore-${item._id}`; - localStorage.setItem( - restoreKey, - JSON.stringify({ - favoritedXtreamIds, - recentlyViewedXtreamIds, - hiddenCategories, - }) - ); - // Navigate to the playlist to trigger re-import this.router.navigate(['/workspace', 'xtreams', item._id]); } catch (error) { diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index 305aa20e7..56e5d29bc 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -274,12 +274,117 @@ describe('withContent import state', () => { expect(store.isImporting()).toBe(false); expect(store.isContentInitialized()).toBe(true); expect(store.contentInitBlockReason()).toBeNull(); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'ready', + series: 'ready', + }); expect(store.activeImportOperationIds()).toEqual([]); expect(store.importPhase()).toBeNull(); expect(store.importCount()).toBe(0); expect(store.itemsToImport()).toBe(0); }); + it('marks content types ready and patches their streams as each import completes', async () => { + const pendingCategories = { + live: createDeferred(), + vod: createDeferred(), + series: createDeferred(), + }; + const pending = { + live: createDeferred(), + movie: createDeferred(), + series: createDeferred(), + }; + + const liveItems = [ + { + xtream_id: 101, + category_id: 11, + title: 'Live One', + }, + ]; + const vodItems = [ + { + xtream_id: 202, + category_id: 22, + title: 'Movie One', + }, + ]; + const seriesItems = [ + { + xtream_id: 303, + category_id: 33, + title: 'Series One', + }, + ]; + + dataSource.getCategories.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: 'live' | 'vod' | 'series' + ) => pendingCategories[type].promise + ); + dataSource.getContent.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: ContentType + ) => pending[type].promise + ); + + const initialization = store.initializeContent(); + await Promise.resolve(); + + expect(store.contentLoadStateByType()).toEqual({ + live: 'loading', + vod: 'loading', + series: 'loading', + }); + + pendingCategories.live.resolve([]); + pendingCategories.vod.resolve([]); + pendingCategories.series.resolve([]); + + pending.live.resolve(liveItems); + await waitForCondition( + () => store.contentLoadStateByType().live === 'ready' + ); + + expect(store.liveStreams()).toEqual(liveItems); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'loading', + series: 'loading', + }); + expect(store.isContentInitialized()).toBe(false); + + pending.movie.resolve(vodItems); + await waitForCondition( + () => store.contentLoadStateByType().vod === 'ready' + ); + + expect(store.vodStreams()).toEqual(vodItems); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'ready', + series: 'loading', + }); + expect(store.isContentInitialized()).toBe(false); + + pending.series.resolve(seriesItems); + await initialization; + + expect(store.serialStreams()).toEqual(seriesItems); + expect(store.contentLoadStateByType()).toEqual({ + live: 'ready', + vod: 'ready', + series: 'ready', + }); + expect(store.isContentInitialized()).toBe(true); + }); + it('loads categories before starting content import', async () => { const pendingCategories = { live: createDeferred(), @@ -405,6 +510,11 @@ describe('withContent import state', () => { expect(store.isCancellingImport()).toBe(false); expect(store.isContentInitialized()).toBe(false); expect(store.contentInitBlockReason()).toBe('cancelled'); + expect(store.contentLoadStateByType()).toEqual({ + live: 'idle', + vod: 'idle', + series: 'idle', + }); await store.initializeContent(); @@ -575,6 +685,11 @@ describe('withContent import state', () => { expect(store.isCancellingImport()).toBe(false); expect(store.isContentInitialized()).toBe(false); expect(store.contentInitBlockReason()).toBe('cancelled'); + expect(store.contentLoadStateByType()).toEqual({ + live: 'idle', + vod: 'idle', + series: 'idle', + }); expect(store.activeImportOperationIds()).toEqual([]); expect(store.importPhase()).toBeNull(); expect(store.importCount()).toBe(0); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index 091988b1a..e67448ae1 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -31,6 +31,8 @@ import { ContentType, PortalStatusType, XtreamContentInitBlockReason, + XtreamContentLoadState, + XtreamContentLoadStateByType, } from '../../xtream-state'; const cancelledPlaylistInitializationLockKey = ( @@ -89,6 +91,7 @@ export interface ContentState { isLoadingCategories: boolean; isLoadingContent: boolean; isImporting: boolean; + contentLoadStateByType: XtreamContentLoadStateByType; isCancellingImport: boolean; importCount: number; importPhase: string | null; @@ -102,6 +105,12 @@ export interface ContentState { /** * Initial content state */ +const initialContentLoadStateByType: XtreamContentLoadStateByType = { + live: 'idle', + vod: 'idle', + series: 'idle', +}; + const initialContentState: ContentState = { liveCategories: [], vodCategories: [], @@ -112,6 +121,7 @@ const initialContentState: ContentState = { isLoadingCategories: false, isLoadingContent: false, isImporting: false, + contentLoadStateByType: { ...initialContentLoadStateByType }, isCancellingImport: false, importCount: 0, importPhase: null, @@ -201,6 +211,18 @@ export function withContent() { const importTypes: ContentType[] = ['live', 'vod', 'series']; let activeInitializationPromise: Promise | null = null; + const updateContentTypeLoadState = ( + type: ContentType, + loadState: XtreamContentLoadState + ): void => { + patchState(store, (state) => ({ + contentLoadStateByType: { + ...state.contentLoadStateByType, + [type]: loadState, + }, + })); + }; + const resolveInitBlockReason = ( portalStatus: PortalStatusType | null | undefined ): XtreamContentInitBlockReason | null => { @@ -356,6 +378,29 @@ export function withContent() { } }; + const finalizePendingContentLoadStates = ( + completedTypes: Set, + loadState: XtreamContentLoadState + ): void => { + patchState(store, (state) => { + const nextLoadStates = { + ...state.contentLoadStateByType, + }; + + for (const type of importTypes) { + if (completedTypes.has(type)) { + continue; + } + + nextLoadStates[type] = loadState; + } + + return { + contentLoadStateByType: nextLoadStates, + }; + }); + }; + const executeContentInitialization = async ( ignoreBlockedState = false ): Promise => { @@ -397,6 +442,11 @@ export function withContent() { itemsToImport: 0, activeImportSessionId: importSessionId, activeImportOperationIds: [], + contentLoadStateByType: { + live: 'loading', + vod: 'loading', + series: 'loading', + }, }); const completedTypes = new Set(); @@ -472,6 +522,11 @@ export function withContent() { ); } + finalizePendingContentLoadStates( + completedTypes, + isDbAbortError(error) ? 'idle' : 'error' + ); + if (isDbAbortError(error)) { patchState(store, (state) => ({ contentInitBlockReason: @@ -656,6 +711,10 @@ export function withContent() { throwIfImportCancelled(options?.importSessionId); await setImportStatus(ctx.playlistId, 'live', 'completed'); options?.completedTypes?.add('live'); + patchState(store, { + liveStreams: live, + }); + updateContentTypeLoadState('live', 'ready'); throwIfImportCancelled(options?.importSessionId); const vodOperationId = databaseService.createOperationId( @@ -682,6 +741,10 @@ export function withContent() { throwIfImportCancelled(options?.importSessionId); await setImportStatus(ctx.playlistId, 'vod', 'completed'); options?.completedTypes?.add('vod'); + patchState(store, { + vodStreams: vod, + }); + updateContentTypeLoadState('vod', 'ready'); throwIfImportCancelled(options?.importSessionId); const seriesOperationId = @@ -709,13 +772,11 @@ export function withContent() { throwIfImportCancelled(options?.importSessionId); await setImportStatus(ctx.playlistId, 'series', 'completed'); options?.completedTypes?.add('series'); - patchState(store, { - liveStreams: live, - vodStreams: vod, serialStreams: series, isLoadingContent: false, }); + updateContentTypeLoadState('series', 'ready'); } catch (error) { if (!isDbAbortError(error)) { logger.error('Error fetching content', error); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index 8f9e61c59..f62362be5 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -6,7 +6,7 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { ContentType } from '../../xtream-state'; +import { ContentType, XtreamContentLoadState } from '../../xtream-state'; /** * Module-level collator — allocating Intl.Collator is expensive; @@ -101,6 +101,7 @@ interface XtreamSelectionItem { } type ParentSelectionStoreLike = { + contentLoadStateByType?: () => Record; isLoadingContent?: () => boolean; liveCategories?: () => XtreamSelectionCategory[]; liveStreams?: () => XtreamSelectionItem[]; @@ -222,6 +223,23 @@ export function withSelection() { (store as ParentSelectionStoreLike).serialStreams?.() || [] ) ); + const selectedTypeContentState = computed(() => { + const storeAny = store as ParentSelectionStoreLike; + const selectedType = store.selectedContentType(); + return ( + storeAny.contentLoadStateByType?.()?.[selectedType] ?? + 'idle' + ); + }); + const selectedTypeContentLoading = computed( + () => selectedTypeContentState() === 'loading' + ); + const selectedTypeContentReady = computed( + () => selectedTypeContentState() === 'ready' + ); + const selectedTypeCountsReady = computed( + () => selectedTypeContentReady() + ); // --------------------------------------------------------------------------- // Stable filter + sort intermediate. @@ -344,11 +362,17 @@ export function withSelection() { /** * Check if paginated content is loading */ - isPaginatedContentLoading: computed(() => { - // Access parent store loading state (from withContent) - const storeAny = store as ParentSelectionStoreLike; - return storeAny.isLoadingContent?.() || false; - }), + isPaginatedContentLoading: computed(() => + selectedTypeContentLoading() + ), + + selectedTypeContentState, + + selectedTypeContentLoading, + + selectedTypeContentReady, + + selectedTypeCountsReady, /** * Memoized category item counts map. diff --git a/libs/portal/xtream/data-access/src/lib/xtream-state.ts b/libs/portal/xtream/data-access/src/lib/xtream-state.ts index 1f1ee5af0..0f79c9b31 100644 --- a/libs/portal/xtream/data-access/src/lib/xtream-state.ts +++ b/libs/portal/xtream/data-access/src/lib/xtream-state.ts @@ -12,6 +12,11 @@ import { } from './data-sources/xtream-data-source.interface'; export type ContentType = 'live' | 'vod' | 'series'; +export type XtreamContentLoadState = 'idle' | 'loading' | 'ready' | 'error'; +export type XtreamContentLoadStateByType = Record< + ContentType, + XtreamContentLoadState +>; export type PortalStatusType = | 'active' @@ -35,6 +40,7 @@ export interface XtreamState { isLoadingCategories: boolean; isLoadingContent: boolean; isImporting: boolean; + contentLoadStateByType: XtreamContentLoadStateByType; liveCategories: XtreamCategory[]; vodCategories: XtreamCategory[]; serialCategories: XtreamCategory[];