mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat: enhance content loading state management and improve recent playlists component tests
This commit is contained in:
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[];
|
||||
|
||||
Reference in new issue
Block a user