feat: enhance content loading state management and improve recent playlists component tests

This commit is contained in:
4gray committed 2026-04-04 20:42:22 +02:00
1 parent eef18dea68
commit a0a084fef7
6 files changed
+262 -20

No files matched your search

@@ -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<boolean>();
@@ -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<void> | 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<void> }) => {
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();
});
@@ -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) {
@@ -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<any[]>(),
vod: createDeferred<any[]>(),
series: createDeferred<any[]>(),
};
const pending = {
live: createDeferred<any[]>(),
movie: createDeferred<any[]>(),
series: createDeferred<any[]>(),
};
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<any[]>(),
@@ -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);
@@ -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<void> | 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<ContentType>,
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<void> => {
@@ -397,6 +442,11 @@ export function withContent() {
itemsToImport: 0,
activeImportSessionId: importSessionId,
activeImportOperationIds: [],
contentLoadStateByType: {
live: 'loading',
vod: 'loading',
series: 'loading',
},
});
const completedTypes = new Set<ContentType>();
@@ -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);
@@ -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<ContentType, XtreamContentLoadState>;
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.
@@ -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[];