mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
refactor: enhance live stream layout with loading state and caching
- Added `XtreamCachedContentScope` type to manage content loading states. - Updated live stream layout to show a loading skeleton when content is being fetched. - Introduced styles for loading content in the live stream layout. - Enhanced unit tests to cover loading states and caching behavior. - Modified `XtreamWorkspaceRouteSession` to handle cached content loading and state management. - Improved handling of optional connection values in playlist data. - Updated workspace context panel to reflect import states and loading conditions. Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
1 parent
a6e886116c
commit
cbedb22698
15 files changed
+1211
-120
No files matched your search
@@ -54,7 +54,12 @@ Provider route integration:
|
||||
3. Xtream and Stalker parent routes attach route-scoped session providers that
|
||||
bootstrap the active playlist, sync provider section state, and clean up
|
||||
provider-local state when the route is destroyed.
|
||||
4. Workspace routes no longer rely on nested provider shell components for
|
||||
4. Xtream route bootstrap is DB-first for already imported Electron playlists:
|
||||
if the requested section has persisted categories and content, the route
|
||||
hydrates from SQLite even when the portal status probe reports unavailable,
|
||||
expired, or inactive. Fresh/no-cache Xtream routes still use the status probe
|
||||
to block remote imports before the loading overlay starts.
|
||||
5. Workspace routes no longer rely on nested provider shell components for
|
||||
hidden local chrome.
|
||||
|
||||
## Shell Structure
|
||||
|
||||
@@ -223,6 +223,16 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
return this.dbService.getAllXtreamCategories(playlistId, type);
|
||||
}
|
||||
|
||||
async getCachedCategories(
|
||||
playlistId: string,
|
||||
type: CategoryType
|
||||
): Promise<XtreamCategoryFromDb[]> {
|
||||
return this.dbService.getXtreamCategories(
|
||||
playlistId,
|
||||
mapCategoryTypeToDbType(type)
|
||||
);
|
||||
}
|
||||
|
||||
async saveCategories(
|
||||
playlistId: string,
|
||||
categories: XtreamCategory[],
|
||||
@@ -330,6 +340,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
return this.dbService.getXtreamContent(playlistId, type);
|
||||
}
|
||||
|
||||
async getCachedContent(
|
||||
playlistId: string,
|
||||
type: StreamType
|
||||
): Promise<XtreamContentItem[]> {
|
||||
return this.dbService.getXtreamContent(playlistId, type);
|
||||
}
|
||||
|
||||
async saveContent(
|
||||
playlistId: string,
|
||||
streams:
|
||||
|
||||
@@ -171,6 +171,15 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCachedCategories(
|
||||
playlistId: string,
|
||||
type: CategoryType
|
||||
): Promise<XtreamCategoryFromDb[]> {
|
||||
void playlistId;
|
||||
void type;
|
||||
return [];
|
||||
}
|
||||
|
||||
async saveCategories(
|
||||
playlistId: string,
|
||||
categories: XtreamCategory[],
|
||||
@@ -240,6 +249,15 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
return content;
|
||||
}
|
||||
|
||||
async getCachedContent(
|
||||
playlistId: string,
|
||||
type: StreamType
|
||||
): Promise<XtreamContentItem[]> {
|
||||
void playlistId;
|
||||
void type;
|
||||
return [];
|
||||
}
|
||||
|
||||
async saveContent(
|
||||
playlistId: string,
|
||||
streams:
|
||||
|
||||
@@ -182,6 +182,15 @@ export interface IXtreamDataSource {
|
||||
options?: XtreamOperationOptions
|
||||
): Promise<XtreamCategory[] | XtreamCategoryFromDb[]>;
|
||||
|
||||
/**
|
||||
* Get persisted categories without contacting the Xtream API.
|
||||
* Electron reads SQLite; PWA has no persisted DB cache and returns [].
|
||||
*/
|
||||
getCachedCategories(
|
||||
playlistId: string,
|
||||
type: CategoryType
|
||||
): Promise<XtreamCategoryFromDb[]>;
|
||||
|
||||
/**
|
||||
* Get all categories including hidden (for management)
|
||||
*/
|
||||
@@ -238,6 +247,15 @@ export interface IXtreamDataSource {
|
||||
| XtreamContentItem[]
|
||||
>;
|
||||
|
||||
/**
|
||||
* Get persisted content without contacting the Xtream API.
|
||||
* Electron reads SQLite; PWA has no persisted DB cache and returns [].
|
||||
*/
|
||||
getCachedContent(
|
||||
playlistId: string,
|
||||
type: StreamType
|
||||
): Promise<XtreamContentItem[]>;
|
||||
|
||||
/**
|
||||
* Save content in bulk
|
||||
*/
|
||||
|
||||
+311
-37
@@ -36,6 +36,7 @@ const TestContentStore = signalStore(
|
||||
playlistId: PLAYLIST.id,
|
||||
currentPlaylist: PLAYLIST,
|
||||
portalStatus: 'active' as PortalStatusType,
|
||||
selectedContentType: 'vod' as const,
|
||||
}),
|
||||
withMethods((store) => ({
|
||||
async checkPortalStatus(): Promise<PortalStatusType> {
|
||||
@@ -84,7 +85,9 @@ describe('withContent import state', () => {
|
||||
let store: InstanceType<typeof TestContentStore>;
|
||||
let dataSource: {
|
||||
getCategories: jest.Mock;
|
||||
getCachedCategories: jest.Mock;
|
||||
getContent: jest.Mock;
|
||||
getCachedContent: jest.Mock;
|
||||
hasCategories: jest.Mock;
|
||||
hasContent: jest.Mock;
|
||||
restoreUserData: jest.Mock;
|
||||
@@ -107,9 +110,11 @@ describe('withContent import state', () => {
|
||||
let operationCounter = 0;
|
||||
dataSource = {
|
||||
getCategories: jest.fn().mockResolvedValue([]),
|
||||
getCachedCategories: jest.fn().mockResolvedValue([]),
|
||||
getContent: jest.fn(),
|
||||
getCachedContent: jest.fn().mockResolvedValue([]),
|
||||
hasCategories: jest.fn().mockResolvedValue(true),
|
||||
hasContent: jest.fn().mockResolvedValue(true),
|
||||
hasContent: jest.fn().mockResolvedValue(false),
|
||||
restoreUserData: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
databaseService = {
|
||||
@@ -456,54 +461,283 @@ describe('withContent import state', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports offline cache availability only when every import type is completed and cached', async () => {
|
||||
databaseService.getXtreamImportStatus
|
||||
.mockResolvedValueOnce('completed')
|
||||
.mockResolvedValueOnce('completed')
|
||||
.mockResolvedValueOnce('completed');
|
||||
dataSource.hasCategories
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
dataSource.hasContent
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
it('reports section cache availability from persisted categories and content', async () => {
|
||||
dataSource.hasCategories.mockResolvedValueOnce(true);
|
||||
dataSource.hasContent.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(store.hasUsableOfflineCache()).resolves.toBe(true);
|
||||
await expect(store.hasUsableOfflineCache('vod')).resolves.toBe(true);
|
||||
|
||||
expect(databaseService.getXtreamImportStatus).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect(dataSource.hasCategories).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
'live'
|
||||
'movies'
|
||||
);
|
||||
expect(databaseService.getXtreamImportStatus).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect(dataSource.hasContent).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
'movie'
|
||||
);
|
||||
expect(databaseService.getXtreamImportStatus).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
PLAYLIST.id,
|
||||
'series'
|
||||
);
|
||||
expect(databaseService.getXtreamImportStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats partial cache as unusable for offline initialization', async () => {
|
||||
databaseService.getXtreamImportStatus
|
||||
.mockResolvedValueOnce('completed')
|
||||
.mockResolvedValueOnce('completed')
|
||||
.mockResolvedValueOnce('completed');
|
||||
dataSource.hasCategories
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
it('does not require every content type for aggregate cached sections', async () => {
|
||||
dataSource.hasContent
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(store.hasUsableOfflineCache()).resolves.toBe(false);
|
||||
await expect(
|
||||
store.hasUsableOfflineCache('recently-added')
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(dataSource.hasCategories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats missing category or content cache as unusable for a concrete section', async () => {
|
||||
dataSource.hasCategories.mockResolvedValueOnce(true);
|
||||
dataSource.hasContent.mockResolvedValueOnce(false);
|
||||
|
||||
await expect(store.hasUsableOfflineCache('live')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('hydrates persisted section content without remote API loading', async () => {
|
||||
dataSource.getCachedCategories.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Movies',
|
||||
playlist_id: PLAYLIST.id,
|
||||
type: 'movies',
|
||||
xtream_id: 10,
|
||||
hidden: false,
|
||||
},
|
||||
]);
|
||||
dataSource.getCachedContent.mockResolvedValueOnce([
|
||||
{
|
||||
id: 100,
|
||||
category_id: 1,
|
||||
title: 'Cached Movie',
|
||||
rating: '',
|
||||
added: '1',
|
||||
poster_url: '',
|
||||
xtream_id: 1000,
|
||||
type: 'movie',
|
||||
},
|
||||
]);
|
||||
|
||||
await store.hydrateCachedContent('vod');
|
||||
|
||||
expect(dataSource.getCachedCategories).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
'vod'
|
||||
);
|
||||
expect(dataSource.getCachedContent).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
'movie'
|
||||
);
|
||||
expect(dataSource.getCategories).not.toHaveBeenCalled();
|
||||
expect(dataSource.getContent).not.toHaveBeenCalled();
|
||||
expect(store.vodCategories()).toHaveLength(1);
|
||||
expect(store.vodStreams()).toHaveLength(1);
|
||||
expect(store.contentLoadStateByType().vod).toBe('ready');
|
||||
expect(store.isCachedContentScopeReady('vod')).toBe(true);
|
||||
expect(store.isContentInitialized()).toBe(true);
|
||||
expect(store.contentInitBlockReason()).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes loading state while cached section content is hydrating', async () => {
|
||||
const cachedCategories = createDeferred<any[]>();
|
||||
const cachedContent = createDeferred<any[]>();
|
||||
dataSource.getCachedCategories.mockReturnValueOnce(
|
||||
cachedCategories.promise
|
||||
);
|
||||
dataSource.getCachedContent.mockReturnValueOnce(cachedContent.promise);
|
||||
|
||||
const hydration = store.hydrateCachedContent('vod');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.isLoadingCategories()).toBe(true);
|
||||
expect(store.isLoadingContent()).toBe(true);
|
||||
expect(store.contentLoadStateByType().vod).toBe('loading');
|
||||
expect(store.isContentInitialized()).toBe(false);
|
||||
|
||||
cachedCategories.resolve([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Movies',
|
||||
playlist_id: PLAYLIST.id,
|
||||
type: 'movies',
|
||||
xtream_id: 10,
|
||||
hidden: false,
|
||||
},
|
||||
]);
|
||||
cachedContent.resolve([
|
||||
{
|
||||
id: 100,
|
||||
category_id: 1,
|
||||
title: 'Cached Movie',
|
||||
rating: '',
|
||||
added: '1',
|
||||
poster_url: '',
|
||||
xtream_id: 1000,
|
||||
type: 'movie',
|
||||
},
|
||||
]);
|
||||
|
||||
await hydration;
|
||||
|
||||
expect(store.isLoadingCategories()).toBe(false);
|
||||
expect(store.isLoadingContent()).toBe(false);
|
||||
expect(store.contentLoadStateByType().vod).toBe('ready');
|
||||
expect(store.vodStreams()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('coalesces concurrent cached hydration calls for the same scope', async () => {
|
||||
const cachedCategories = createDeferred<any[]>();
|
||||
const cachedContent = createDeferred<any[]>();
|
||||
dataSource.getCachedCategories.mockReturnValueOnce(
|
||||
cachedCategories.promise
|
||||
);
|
||||
dataSource.getCachedContent.mockReturnValueOnce(cachedContent.promise);
|
||||
|
||||
const firstHydration = store.hydrateCachedContent('vod');
|
||||
await Promise.resolve();
|
||||
const secondHydration = store.hydrateCachedContent('vod');
|
||||
|
||||
expect(dataSource.getCachedCategories).toHaveBeenCalledTimes(1);
|
||||
expect(dataSource.getCachedContent).toHaveBeenCalledTimes(1);
|
||||
|
||||
cachedCategories.resolve([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Movies',
|
||||
playlist_id: PLAYLIST.id,
|
||||
type: 'movies',
|
||||
xtream_id: 10,
|
||||
hidden: false,
|
||||
},
|
||||
]);
|
||||
cachedContent.resolve([
|
||||
{
|
||||
id: 100,
|
||||
category_id: 1,
|
||||
title: 'Cached Movie',
|
||||
rating: '',
|
||||
added: '1',
|
||||
poster_url: '',
|
||||
xtream_id: 1000,
|
||||
type: 'movie',
|
||||
},
|
||||
]);
|
||||
|
||||
await Promise.all([firstHydration, secondHydration]);
|
||||
|
||||
expect(store.contentLoadStateByType().vod).toBe('ready');
|
||||
expect(store.vodStreams()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps initialized offline content visible while hydrating another cached type', async () => {
|
||||
dataSource.getCachedCategories.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Movies',
|
||||
playlist_id: PLAYLIST.id,
|
||||
type: 'movies',
|
||||
xtream_id: 10,
|
||||
hidden: false,
|
||||
},
|
||||
]);
|
||||
dataSource.getCachedContent.mockResolvedValueOnce([
|
||||
{
|
||||
id: 100,
|
||||
category_id: 1,
|
||||
title: 'Cached Movie',
|
||||
rating: '',
|
||||
added: '1',
|
||||
poster_url: '',
|
||||
xtream_id: 1000,
|
||||
type: 'movie',
|
||||
},
|
||||
]);
|
||||
|
||||
await store.hydrateCachedContent('vod');
|
||||
expect(store.isContentInitialized()).toBe(true);
|
||||
|
||||
dataSource.getCachedCategories.mockClear();
|
||||
dataSource.getCachedContent.mockClear();
|
||||
|
||||
const cachedSeriesCategories = createDeferred<any[]>();
|
||||
const cachedSeriesContent = createDeferred<any[]>();
|
||||
dataSource.getCachedCategories.mockReturnValueOnce(
|
||||
cachedSeriesCategories.promise
|
||||
);
|
||||
dataSource.getCachedContent.mockReturnValueOnce(
|
||||
cachedSeriesContent.promise
|
||||
);
|
||||
|
||||
const hydration = store.hydrateCachedContent('series');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.isContentInitialized()).toBe(true);
|
||||
expect(store.contentLoadStateByType()).toEqual({
|
||||
live: 'idle',
|
||||
vod: 'ready',
|
||||
series: 'loading',
|
||||
});
|
||||
|
||||
cachedSeriesCategories.resolve([
|
||||
{
|
||||
id: 2,
|
||||
name: 'Series',
|
||||
playlist_id: PLAYLIST.id,
|
||||
type: 'series',
|
||||
xtream_id: 20,
|
||||
hidden: false,
|
||||
},
|
||||
]);
|
||||
cachedSeriesContent.resolve([
|
||||
{
|
||||
id: 200,
|
||||
category_id: 2,
|
||||
title: 'Cached Series',
|
||||
rating: '',
|
||||
added: '1',
|
||||
poster_url: '',
|
||||
xtream_id: 2000,
|
||||
type: 'series',
|
||||
},
|
||||
]);
|
||||
|
||||
await hydration;
|
||||
|
||||
expect(store.isContentInitialized()).toBe(true);
|
||||
expect(store.contentLoadStateByType().series).toBe('ready');
|
||||
expect(store.serialStreams()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('marks aggregate cached hydration scopes ready even when some types are empty', async () => {
|
||||
await store.hydrateCachedContent('recently-added');
|
||||
|
||||
expect(dataSource.getCachedCategories).toHaveBeenCalledTimes(3);
|
||||
expect(dataSource.getCachedContent).toHaveBeenCalledTimes(3);
|
||||
expect(store.contentLoadStateByType()).toEqual({
|
||||
live: 'ready',
|
||||
vod: 'ready',
|
||||
series: 'ready',
|
||||
});
|
||||
expect(store.isCachedContentScopeReady('recently-added')).toBe(true);
|
||||
expect(store.isContentInitialized()).toBe(true);
|
||||
});
|
||||
|
||||
it('can mark a routed section as loading before async bootstrap starts', () => {
|
||||
store.prepareContentLoading('series');
|
||||
|
||||
expect(store.isLoadingCategories()).toBe(true);
|
||||
expect(store.isLoadingContent()).toBe(true);
|
||||
expect(store.isContentInitialized()).toBe(false);
|
||||
expect(store.contentInitBlockReason()).toBeNull();
|
||||
expect(store.contentLoadStateByType()).toEqual({
|
||||
live: 'idle',
|
||||
vod: 'idle',
|
||||
series: 'loading',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores concurrent initializeContent calls while an import is already running', async () => {
|
||||
@@ -645,6 +879,46 @@ describe('withContent import state', () => {
|
||||
expect(dataSource.getContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hydrates cached content on retry when status remains unavailable', async () => {
|
||||
checkPortalStatusMock.mockResolvedValue('unavailable');
|
||||
dataSource.hasCategories.mockResolvedValueOnce(true);
|
||||
dataSource.hasContent.mockResolvedValueOnce(true);
|
||||
dataSource.getCachedCategories.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Movies',
|
||||
playlist_id: PLAYLIST.id,
|
||||
type: 'movies',
|
||||
xtream_id: 10,
|
||||
hidden: false,
|
||||
},
|
||||
]);
|
||||
dataSource.getCachedContent.mockResolvedValueOnce([
|
||||
{
|
||||
id: 100,
|
||||
category_id: 1,
|
||||
title: 'Cached Movie',
|
||||
rating: '',
|
||||
added: '1',
|
||||
poster_url: '',
|
||||
xtream_id: 1000,
|
||||
type: 'movie',
|
||||
},
|
||||
]);
|
||||
store.setContentInitBlockReason('unavailable');
|
||||
|
||||
await store.retryContentInitialization();
|
||||
|
||||
expect(checkPortalStatusMock).toHaveBeenCalledTimes(1);
|
||||
expect(dataSource.getCachedContent).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
'movie'
|
||||
);
|
||||
expect(store.contentInitBlockReason()).toBeNull();
|
||||
expect(store.vodStreams()).toHaveLength(1);
|
||||
expect(dataSource.getContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a cancelled block sticky until retry or reset clears it', async () => {
|
||||
store.setContentInitBlockReason('cancelled');
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import {
|
||||
ContentType,
|
||||
PortalStatusType,
|
||||
XtreamCachedContentScope,
|
||||
XtreamContentInitBlockReason,
|
||||
XtreamContentLoadState,
|
||||
XtreamContentLoadStateByType,
|
||||
@@ -146,6 +147,7 @@ export function withContent() {
|
||||
const logger = createLogger('withContent');
|
||||
type ParentPortalStoreLike = {
|
||||
currentPlaylist?: () => {
|
||||
id?: string;
|
||||
password: string;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
@@ -153,6 +155,7 @@ export function withContent() {
|
||||
playlistId?: () => string | null;
|
||||
portalStatus?: () => PortalStatusType;
|
||||
checkPortalStatus?: () => Promise<PortalStatusType>;
|
||||
selectedContentType?: () => ContentType | undefined;
|
||||
};
|
||||
|
||||
return signalStoreFeature(
|
||||
@@ -214,6 +217,83 @@ export function withContent() {
|
||||
const xtreamApiService = inject(XtreamApiService);
|
||||
const importTypes: ContentType[] = ['live', 'vod', 'series'];
|
||||
let activeInitializationPromise: Promise<void> | null = null;
|
||||
let cachedHydrationGeneration = 0;
|
||||
const activeCachedHydrationPromises = new Map<
|
||||
string,
|
||||
Promise<void>
|
||||
>();
|
||||
|
||||
const getCachedHydrationKey = (
|
||||
playlistId: string,
|
||||
scope?: XtreamCachedContentScope | null
|
||||
): string => `${playlistId}:${scope ?? 'all'}`;
|
||||
|
||||
const toDbCategoryType = (type: ContentType): DbCategoryType => {
|
||||
switch (type) {
|
||||
case 'live':
|
||||
return 'live';
|
||||
case 'vod':
|
||||
return 'movies';
|
||||
case 'series':
|
||||
return 'series';
|
||||
}
|
||||
};
|
||||
|
||||
const toCategoryType = (
|
||||
type: ContentType
|
||||
): 'live' | 'vod' | 'series' => type;
|
||||
|
||||
const toStreamType = (
|
||||
type: ContentType
|
||||
): 'live' | 'movie' | 'series' => {
|
||||
return type === 'vod' ? 'movie' : type;
|
||||
};
|
||||
|
||||
const getTypesForCacheScope = (
|
||||
scope?: XtreamCachedContentScope | null
|
||||
): ContentType[] => {
|
||||
switch (scope) {
|
||||
case 'live':
|
||||
case 'vod':
|
||||
case 'series':
|
||||
return [scope];
|
||||
case 'search':
|
||||
case 'recently-added':
|
||||
default:
|
||||
return importTypes;
|
||||
}
|
||||
};
|
||||
|
||||
const asCachedContent = <T>(content: unknown): T[] =>
|
||||
content as T[];
|
||||
|
||||
const markContentScopeLoading = (
|
||||
scope?: XtreamCachedContentScope | null,
|
||||
options?: { preserveInitialized?: boolean }
|
||||
): void => {
|
||||
const types = getTypesForCacheScope(scope);
|
||||
|
||||
patchState(store, (state) => {
|
||||
const nextLoadStates = {
|
||||
...state.contentLoadStateByType,
|
||||
};
|
||||
|
||||
for (const type of types) {
|
||||
nextLoadStates[type] = 'loading';
|
||||
}
|
||||
|
||||
return {
|
||||
isLoadingCategories: true,
|
||||
isLoadingContent: true,
|
||||
isImporting: false,
|
||||
isContentInitialized: options?.preserveInitialized
|
||||
? state.isContentInitialized
|
||||
: false,
|
||||
contentInitBlockReason: null,
|
||||
contentLoadStateByType: nextLoadStates,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateContentTypeLoadState = (
|
||||
type: ContentType,
|
||||
@@ -301,42 +381,224 @@ export function withContent() {
|
||||
};
|
||||
};
|
||||
|
||||
const hasCompletedOfflineCache = async (
|
||||
playlistId: string
|
||||
const hasCachedContentForType = async (
|
||||
playlistId: string,
|
||||
type: ContentType
|
||||
): Promise<boolean> => {
|
||||
const contentTypes: Array<{
|
||||
categoryType: DbCategoryType;
|
||||
contentType: 'live' | 'movie' | 'series';
|
||||
}> = [
|
||||
{ categoryType: 'live', contentType: 'live' },
|
||||
{ categoryType: 'movies', contentType: 'movie' },
|
||||
{ categoryType: 'series', contentType: 'series' },
|
||||
];
|
||||
const [hasCategories, hasContent] = await Promise.all([
|
||||
dataSource.hasCategories(playlistId, toDbCategoryType(type)),
|
||||
dataSource.hasContent(playlistId, toStreamType(type)),
|
||||
]);
|
||||
|
||||
const cacheChecks = await Promise.all(
|
||||
contentTypes.map(async ({ categoryType, contentType }) => {
|
||||
const [importStatus, hasCategories, hasContent] =
|
||||
await Promise.all([
|
||||
databaseService.getXtreamImportStatus(
|
||||
return hasCategories && hasContent;
|
||||
};
|
||||
|
||||
const hasCachedContentForScope = async (
|
||||
playlistId: string,
|
||||
scope?: XtreamCachedContentScope | null
|
||||
): Promise<boolean> => {
|
||||
const types = getTypesForCacheScope(scope);
|
||||
|
||||
if (scope === 'search' || scope === 'recently-added' || !scope) {
|
||||
const checks = await Promise.all(
|
||||
types.map((type) =>
|
||||
dataSource.hasContent(playlistId, toStreamType(type))
|
||||
)
|
||||
);
|
||||
return checks.some(Boolean);
|
||||
}
|
||||
|
||||
return hasCachedContentForType(playlistId, scope);
|
||||
};
|
||||
|
||||
const isCurrentCachedHydrationContext = (
|
||||
playlistId: string,
|
||||
generation: number
|
||||
): boolean => {
|
||||
if (cachedHydrationGeneration !== generation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storeAny = getPortalStore();
|
||||
const currentPlaylist = storeAny.currentPlaylist?.();
|
||||
const currentPlaylistId = storeAny.playlistId?.();
|
||||
|
||||
return (
|
||||
currentPlaylistId === playlistId &&
|
||||
(!currentPlaylist?.id || currentPlaylist.id === playlistId)
|
||||
);
|
||||
};
|
||||
|
||||
const isCachedContentScopeReady = (
|
||||
scope?: XtreamCachedContentScope | null
|
||||
): boolean => {
|
||||
const types = getTypesForCacheScope(scope);
|
||||
const loadStates = store.contentLoadStateByType();
|
||||
return types.every((type) => loadStates[type] === 'ready');
|
||||
};
|
||||
|
||||
const executeCachedContentHydration = async (
|
||||
playlistId: string,
|
||||
scope: XtreamCachedContentScope | null | undefined,
|
||||
generation: number
|
||||
): Promise<void> => {
|
||||
const types = getTypesForCacheScope(scope);
|
||||
markContentScopeLoading(scope, {
|
||||
preserveInitialized: store.isContentInitialized(),
|
||||
});
|
||||
|
||||
let cachedEntries: Array<{
|
||||
type: ContentType;
|
||||
categories: Awaited<
|
||||
ReturnType<typeof dataSource.getCachedCategories>
|
||||
>;
|
||||
content: Awaited<
|
||||
ReturnType<typeof dataSource.getCachedContent>
|
||||
>;
|
||||
}>;
|
||||
|
||||
try {
|
||||
cachedEntries = await Promise.all(
|
||||
types.map(async (type) => {
|
||||
const [categories, content] = await Promise.all([
|
||||
dataSource.getCachedCategories(
|
||||
playlistId,
|
||||
contentType
|
||||
toCategoryType(type)
|
||||
),
|
||||
dataSource.hasCategories(
|
||||
dataSource.getCachedContent(
|
||||
playlistId,
|
||||
categoryType
|
||||
toStreamType(type)
|
||||
),
|
||||
dataSource.hasContent(playlistId, contentType),
|
||||
]);
|
||||
|
||||
return (
|
||||
importStatus === 'completed' &&
|
||||
hasCategories &&
|
||||
hasContent
|
||||
);
|
||||
})
|
||||
);
|
||||
return { type, categories, content };
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
!isCurrentCachedHydrationContext(
|
||||
playlistId,
|
||||
generation
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return cacheChecks.every(Boolean);
|
||||
const errorBlockReason: XtreamContentInitBlockReason =
|
||||
'error';
|
||||
patchState(store, (state) => {
|
||||
const nextLoadStates = {
|
||||
...state.contentLoadStateByType,
|
||||
};
|
||||
|
||||
for (const type of types) {
|
||||
nextLoadStates[type] = 'error';
|
||||
}
|
||||
|
||||
return {
|
||||
isLoadingCategories: false,
|
||||
isLoadingContent: false,
|
||||
contentInitBlockReason: errorBlockReason,
|
||||
contentLoadStateByType: nextLoadStates,
|
||||
};
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (
|
||||
!isCurrentCachedHydrationContext(playlistId, generation)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
patchState(store, (state) => {
|
||||
const nextLoadStates = {
|
||||
...state.contentLoadStateByType,
|
||||
};
|
||||
const updates: Partial<ContentState> = {
|
||||
isLoadingCategories: false,
|
||||
isLoadingContent: false,
|
||||
isImporting: false,
|
||||
isContentInitialized: true,
|
||||
contentInitBlockReason: null,
|
||||
};
|
||||
|
||||
for (const entry of cachedEntries) {
|
||||
nextLoadStates[entry.type] = 'ready';
|
||||
|
||||
switch (entry.type) {
|
||||
case 'live':
|
||||
updates.liveCategories = entry.categories;
|
||||
updates.liveStreams =
|
||||
asCachedContent<XtreamLiveStream>(
|
||||
entry.content
|
||||
);
|
||||
break;
|
||||
case 'vod':
|
||||
updates.vodCategories = entry.categories;
|
||||
updates.vodStreams =
|
||||
asCachedContent<XtreamVodStream>(
|
||||
entry.content
|
||||
);
|
||||
break;
|
||||
case 'series':
|
||||
updates.serialCategories = entry.categories;
|
||||
updates.serialStreams =
|
||||
asCachedContent<XtreamSerieItem>(
|
||||
entry.content
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updates.contentLoadStateByType = nextLoadStates;
|
||||
return updates;
|
||||
});
|
||||
};
|
||||
|
||||
const hydrateCachedContentForScope = async (
|
||||
scope?: XtreamCachedContentScope | null
|
||||
): Promise<void> => {
|
||||
const ctx = getCredentialsFromStore();
|
||||
if (!ctx) return;
|
||||
|
||||
if (isCachedContentScopeReady(scope)) {
|
||||
patchState(store, {
|
||||
isLoadingCategories: false,
|
||||
isLoadingContent: false,
|
||||
isContentInitialized: true,
|
||||
contentInitBlockReason: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = getCachedHydrationKey(
|
||||
ctx.playlistId,
|
||||
scope
|
||||
);
|
||||
const inFlightRequest =
|
||||
activeCachedHydrationPromises.get(requestKey);
|
||||
|
||||
if (inFlightRequest) {
|
||||
return inFlightRequest;
|
||||
}
|
||||
|
||||
const generation = cachedHydrationGeneration;
|
||||
const request = executeCachedContentHydration(
|
||||
ctx.playlistId,
|
||||
scope,
|
||||
generation
|
||||
).finally(() => {
|
||||
if (
|
||||
activeCachedHydrationPromises.get(requestKey) ===
|
||||
request
|
||||
) {
|
||||
activeCachedHydrationPromises.delete(requestKey);
|
||||
}
|
||||
});
|
||||
|
||||
activeCachedHydrationPromises.set(requestKey, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const trackImportEvent = (event: DbOperationEvent): void => {
|
||||
@@ -877,13 +1139,33 @@ export function withContent() {
|
||||
await runContentInitialization();
|
||||
},
|
||||
|
||||
async hasUsableOfflineCache(): Promise<boolean> {
|
||||
async hasUsableOfflineCache(
|
||||
scope?: XtreamCachedContentScope | null
|
||||
): Promise<boolean> {
|
||||
const ctx = getCredentialsFromStore();
|
||||
if (!ctx) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasCompletedOfflineCache(ctx.playlistId);
|
||||
return hasCachedContentForScope(ctx.playlistId, scope);
|
||||
},
|
||||
|
||||
prepareContentLoading(
|
||||
scope?: XtreamCachedContentScope | null
|
||||
): void {
|
||||
markContentScopeLoading(scope);
|
||||
},
|
||||
|
||||
isCachedContentScopeReady(
|
||||
scope?: XtreamCachedContentScope | null
|
||||
): boolean {
|
||||
return isCachedContentScopeReady(scope);
|
||||
},
|
||||
|
||||
async hydrateCachedContent(
|
||||
scope?: XtreamCachedContentScope | null
|
||||
): Promise<void> {
|
||||
await hydrateCachedContentForScope(scope);
|
||||
},
|
||||
|
||||
async retryContentInitialization(): Promise<void> {
|
||||
@@ -892,8 +1174,25 @@ export function withContent() {
|
||||
getPortalStore().portalStatus?.() ??
|
||||
'unavailable';
|
||||
const blockReason = resolveInitBlockReason(portalStatus);
|
||||
const ctx = getCredentialsFromStore();
|
||||
const cacheScope =
|
||||
getPortalStore().selectedContentType?.() ?? null;
|
||||
|
||||
if (blockReason) {
|
||||
if (
|
||||
ctx &&
|
||||
(await hasCachedContentForScope(
|
||||
ctx.playlistId,
|
||||
cacheScope
|
||||
))
|
||||
) {
|
||||
clearCancelledPlaylistInitializationLock(
|
||||
ctx.playlistId
|
||||
);
|
||||
await hydrateCachedContentForScope(cacheScope);
|
||||
return;
|
||||
}
|
||||
|
||||
patchState(store, {
|
||||
contentInitBlockReason: blockReason,
|
||||
});
|
||||
@@ -904,7 +1203,6 @@ export function withContent() {
|
||||
contentInitBlockReason: null,
|
||||
isContentInitialized: false,
|
||||
});
|
||||
const ctx = getCredentialsFromStore();
|
||||
if (ctx) {
|
||||
clearCancelledPlaylistInitializationLock(
|
||||
ctx.playlistId
|
||||
@@ -998,6 +1296,8 @@ export function withContent() {
|
||||
* Reset content state
|
||||
*/
|
||||
resetContent(): void {
|
||||
cachedHydrationGeneration += 1;
|
||||
activeCachedHydrationPromises.clear();
|
||||
patchState(store, initialContentState);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
} from './data-sources/xtream-data-source.interface';
|
||||
|
||||
export type ContentType = 'live' | 'vod' | 'series';
|
||||
export type XtreamCachedContentScope =
|
||||
| ContentType
|
||||
| 'search'
|
||||
| 'recently-added';
|
||||
export type XtreamContentLoadState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
export type XtreamContentLoadStateByType = Record<
|
||||
ContentType,
|
||||
|
||||
+14
-4
@@ -68,10 +68,20 @@
|
||||
}
|
||||
<div class="content-container">
|
||||
@if (!selectedCategoryId()) {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
[message]="'PORTALS.SELECT_CATEGORY' | translate"
|
||||
/>
|
||||
@if (isSelectedTypeContentLoading()) {
|
||||
<div class="live-loading-content">
|
||||
<app-portal-channels-list
|
||||
[sortMode]="liveChannelSortMode()"
|
||||
[searchTermInput]="workspaceSearchTerm()"
|
||||
(playClicked)="playLive($event)"
|
||||
/>
|
||||
</div>
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
[message]="'PORTALS.SELECT_CATEGORY' | translate"
|
||||
/>
|
||||
}
|
||||
} @else if (streamUrl) {
|
||||
@if (isEmbeddedPlayer) {
|
||||
<div class="video-player">
|
||||
|
||||
+11
@@ -19,6 +19,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.live-loading-content {
|
||||
align-self: stretch;
|
||||
width: min(520px, 100%);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.live-loading-content app-portal-channels-list {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
+20
-1
@@ -31,7 +31,7 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
@Component({
|
||||
selector: 'app-portal-channels-list',
|
||||
standalone: true,
|
||||
template: '',
|
||||
template: '<div data-test-id="portal-channels-list-stub"></div>',
|
||||
})
|
||||
class StubPortalChannelsListComponent {
|
||||
readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server');
|
||||
@@ -120,6 +120,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
const epgItems = signal<EpgItem[]>([]);
|
||||
const currentEpgItem = signal<EpgItem | null>(null);
|
||||
const isLoadingEpg = signal(false);
|
||||
const selectedTypeContentLoading = signal(false);
|
||||
const selectedCategoryId = signal<number | null>(1);
|
||||
const selectedContentType = signal<'live' | 'vod' | 'series'>('live');
|
||||
const selectedItem = signal<unknown>(sampleChannel);
|
||||
@@ -131,6 +132,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
epgItems,
|
||||
currentEpgItem,
|
||||
isLoadingEpg,
|
||||
selectedTypeContentLoading,
|
||||
selectedCategoryId,
|
||||
selectedContentType,
|
||||
selectedItem,
|
||||
@@ -176,6 +178,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
epgItems.set([]);
|
||||
currentEpgItem.set(null);
|
||||
isLoadingEpg.set(false);
|
||||
selectedTypeContentLoading.set(false);
|
||||
selectedCategoryId.set(1);
|
||||
selectedContentType.set('live');
|
||||
selectedItem.set(sampleChannel);
|
||||
@@ -315,6 +318,22 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the channel loading skeleton surface while live content loads without a selected category', () => {
|
||||
selectedCategoryId.set(null);
|
||||
selectedTypeContentLoading.set(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="portal-channels-list-stub"]'
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-portal-empty-state')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the current EPG program in the collapsible panel summary', () => {
|
||||
epgItems.set([
|
||||
buildEpgItem(
|
||||
|
||||
@@ -99,6 +99,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
|
||||
readonly epgItems = this.xtreamStore.epgItems;
|
||||
readonly currentEpgItem = this.xtreamStore.currentEpgItem;
|
||||
readonly isSelectedTypeContentLoading =
|
||||
this.xtreamStore.selectedTypeContentLoading;
|
||||
readonly isLoadingEpg = this.xtreamStore.isLoadingEpg;
|
||||
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
|
||||
readonly liveChannelSortMode = signal<PortalChannelSortMode>('server');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { XtreamWorkspaceRouteSession } from './xtream-workspace-route-session.service';
|
||||
|
||||
const PLAYLIST_ID = 'playlist-1';
|
||||
const NEXT_PLAYLIST_ID = 'playlist-2';
|
||||
const ACTIVE_PLAYLIST: PlaylistMeta = {
|
||||
_id: PLAYLIST_ID,
|
||||
filename: 'xtream.m3u',
|
||||
@@ -41,10 +42,12 @@ const XTREAM_PLAYLIST_WITH_NULL_OPTIONALS = {
|
||||
referrer: null,
|
||||
userAgent: null,
|
||||
} as XtreamPlaylistData;
|
||||
type CachedScope = 'live' | 'vod' | 'series' | 'search' | 'recently-added';
|
||||
|
||||
async function flushEffects(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function getXtreamSectionFromUrl(url: string): string | null {
|
||||
@@ -55,8 +58,19 @@ function getXtreamSectionFromUrl(url: string): string | null {
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('XtreamWorkspaceRouteSession', () => {
|
||||
const routerEvents = new Subject<NavigationEnd>();
|
||||
const routerEvents = new Subject<NavigationEnd | NavigationStart>();
|
||||
const routeProvider = signal<'xtreams' | null>('xtreams');
|
||||
const routePlaylistId = signal<string | null>(PLAYLIST_ID);
|
||||
const activePlaylist = signal<PlaylistMeta | null>(ACTIVE_PLAYLIST);
|
||||
@@ -66,6 +80,13 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
const selectedContentType = signal<'live' | 'vod' | 'series'>('vod');
|
||||
const selectedCategoryId = signal<number | null>(null);
|
||||
const isContentInitialized = signal(false);
|
||||
const contentLoadStateByType = signal<
|
||||
Record<'live' | 'vod' | 'series', 'idle' | 'loading' | 'ready' | 'error'>
|
||||
>({
|
||||
live: 'idle',
|
||||
vod: 'idle',
|
||||
series: 'idle',
|
||||
});
|
||||
const contentInitBlockReason =
|
||||
signal<XtreamContentInitBlockReason | null>(null);
|
||||
let hasUsableOfflineCache = false;
|
||||
@@ -86,6 +107,11 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
currentPlaylist.set(null);
|
||||
selectedContentType.set('vod');
|
||||
isContentInitialized.set(false);
|
||||
contentLoadStateByType.set({
|
||||
live: 'idle',
|
||||
vod: 'idle',
|
||||
series: 'idle',
|
||||
});
|
||||
}),
|
||||
setCurrentPlaylist: jest.fn((playlist: XtreamPlaylistData | null) => {
|
||||
currentPlaylist.set(playlist);
|
||||
@@ -95,11 +121,66 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
hasUsableOfflineCache: jest.fn().mockImplementation(async () => {
|
||||
return hasUsableOfflineCache;
|
||||
}),
|
||||
isCachedContentScopeReady: jest.fn(
|
||||
(scope?: CachedScope | null): boolean => {
|
||||
const loadStates = contentLoadStateByType();
|
||||
if (scope === 'live' || scope === 'vod' || scope === 'series') {
|
||||
return loadStates[scope] === 'ready';
|
||||
}
|
||||
|
||||
return (
|
||||
loadStates.live === 'ready' &&
|
||||
loadStates.vod === 'ready' &&
|
||||
loadStates.series === 'ready'
|
||||
);
|
||||
}
|
||||
),
|
||||
isContentInitialized,
|
||||
contentLoadStateByType,
|
||||
contentInitBlockReason,
|
||||
initializeContent: jest.fn().mockImplementation(async () => {
|
||||
isContentInitialized.set(true);
|
||||
contentLoadStateByType.set({
|
||||
live: 'ready',
|
||||
vod: 'ready',
|
||||
series: 'ready',
|
||||
});
|
||||
}),
|
||||
hydrateCachedContent: jest.fn().mockImplementation(
|
||||
async (scope?: CachedScope | null) => {
|
||||
isContentInitialized.set(true);
|
||||
contentInitBlockReason.set(null);
|
||||
if (scope === 'live' || scope === 'vod' || scope === 'series') {
|
||||
contentLoadStateByType.update((state) => ({
|
||||
...state,
|
||||
[scope]: 'ready',
|
||||
}));
|
||||
} else {
|
||||
contentLoadStateByType.set({
|
||||
live: 'ready',
|
||||
vod: 'ready',
|
||||
series: 'ready',
|
||||
});
|
||||
}
|
||||
}
|
||||
),
|
||||
prepareContentLoading: jest.fn(
|
||||
(scope?: CachedScope | null) => {
|
||||
isContentInitialized.set(false);
|
||||
if (scope === 'live' || scope === 'vod' || scope === 'series') {
|
||||
contentLoadStateByType.update((state) => ({
|
||||
...state,
|
||||
[scope]: 'loading',
|
||||
}));
|
||||
} else {
|
||||
contentLoadStateByType.set({
|
||||
live: 'loading',
|
||||
vod: 'loading',
|
||||
series: 'loading',
|
||||
});
|
||||
}
|
||||
}
|
||||
),
|
||||
setSelectedContentType: jest.fn(
|
||||
(type: 'live' | 'vod' | 'series') => {
|
||||
selectedContentType.set(type);
|
||||
@@ -131,6 +212,11 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
selectedContentType.set('vod');
|
||||
selectedCategoryId.set(null);
|
||||
isContentInitialized.set(false);
|
||||
contentLoadStateByType.set({
|
||||
live: 'idle',
|
||||
vod: 'idle',
|
||||
series: 'idle',
|
||||
});
|
||||
contentInitBlockReason.set(null);
|
||||
hasUsableOfflineCache = false;
|
||||
|
||||
@@ -153,7 +239,10 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
xtreamStore.fetchXtreamPlaylist.mockClear();
|
||||
xtreamStore.checkPortalStatus.mockReset();
|
||||
xtreamStore.hasUsableOfflineCache.mockClear();
|
||||
xtreamStore.isCachedContentScopeReady.mockClear();
|
||||
xtreamStore.initializeContent.mockClear();
|
||||
xtreamStore.hydrateCachedContent.mockClear();
|
||||
xtreamStore.prepareContentLoading.mockClear();
|
||||
xtreamStore.setSelectedContentType.mockClear();
|
||||
xtreamStore.setSelectedCategory.mockClear();
|
||||
xtreamStore.setContentInitBlockReason.mockClear();
|
||||
@@ -189,9 +278,78 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
|
||||
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(null);
|
||||
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod');
|
||||
expect(xtreamStore.prepareContentLoading).toHaveBeenCalledWith('vod');
|
||||
expect(xtreamStore.initializeContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the routed section loading before status checks complete during playlist switches', async () => {
|
||||
const portalStatusCheck = createDeferred<PortalStatusType>();
|
||||
xtreamStore.checkPortalStatus.mockReturnValue(
|
||||
portalStatusCheck.promise
|
||||
);
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.resetStore).toHaveBeenCalledWith(PLAYLIST_ID);
|
||||
expect(xtreamStore.prepareContentLoading).toHaveBeenCalledWith('vod');
|
||||
expect(contentLoadStateByType().vod).toBe('loading');
|
||||
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
|
||||
|
||||
portalStatus.set('active');
|
||||
portalStatusCheck.resolve('active');
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.initializeContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prepares loading on navigation start when switching Xtream playlists', async () => {
|
||||
currentPlaylist.set(XTREAM_PLAYLIST);
|
||||
playlistId.set(PLAYLIST_ID);
|
||||
isContentInitialized.set(true);
|
||||
contentLoadStateByType.set({
|
||||
live: 'ready',
|
||||
vod: 'ready',
|
||||
series: 'ready',
|
||||
});
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
xtreamStore.prepareContentLoading.mockClear();
|
||||
|
||||
routerEvents.next(
|
||||
new NavigationStart(
|
||||
1,
|
||||
`/workspace/xtreams/${NEXT_PLAYLIST_ID}/live`
|
||||
)
|
||||
);
|
||||
|
||||
expect(xtreamStore.prepareContentLoading).toHaveBeenCalledWith('live');
|
||||
expect(contentLoadStateByType().live).toBe('loading');
|
||||
});
|
||||
|
||||
it('does not prepare loading on navigation start for category changes in the current playlist', async () => {
|
||||
currentPlaylist.set(XTREAM_PLAYLIST);
|
||||
playlistId.set(PLAYLIST_ID);
|
||||
isContentInitialized.set(true);
|
||||
contentLoadStateByType.set({
|
||||
live: 'ready',
|
||||
vod: 'ready',
|
||||
series: 'ready',
|
||||
});
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
xtreamStore.prepareContentLoading.mockClear();
|
||||
|
||||
routerEvents.next(
|
||||
new NavigationStart(1, `/workspace/xtreams/${PLAYLIST_ID}/vod/202`)
|
||||
);
|
||||
|
||||
expect(xtreamStore.prepareContentLoading).not.toHaveBeenCalled();
|
||||
expect(contentLoadStateByType().vod).toBe('ready');
|
||||
});
|
||||
|
||||
it('reapplies a live route section after resetStore restores the default selection', async () => {
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/live`;
|
||||
xtreamStore.checkPortalStatus.mockImplementation(async () => {
|
||||
@@ -204,6 +362,7 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
|
||||
expect(xtreamStore.resetStore).toHaveBeenCalledWith(PLAYLIST_ID);
|
||||
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('live');
|
||||
expect(xtreamStore.prepareContentLoading).toHaveBeenCalledWith('live');
|
||||
expect(selectedContentType()).toBe('live');
|
||||
expect(
|
||||
xtreamStore.setSelectedContentType.mock.invocationCallOrder[0]
|
||||
@@ -215,6 +374,11 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
currentPlaylist.set(XTREAM_PLAYLIST);
|
||||
playlistId.set(PLAYLIST_ID);
|
||||
isContentInitialized.set(true);
|
||||
contentLoadStateByType.set({
|
||||
live: 'ready',
|
||||
vod: 'ready',
|
||||
series: 'ready',
|
||||
});
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
@@ -227,11 +391,22 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not treat null and undefined Xtream connection metadata as a playlist change', async () => {
|
||||
it('does not treat blank and null Xtream connection metadata as a playlist change', async () => {
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`;
|
||||
activePlaylist.set({
|
||||
...ACTIVE_PLAYLIST,
|
||||
origin: '',
|
||||
referrer: '',
|
||||
userAgent: '',
|
||||
} as PlaylistMeta);
|
||||
currentPlaylist.set(XTREAM_PLAYLIST_WITH_NULL_OPTIONALS);
|
||||
playlistId.set(PLAYLIST_ID);
|
||||
isContentInitialized.set(true);
|
||||
contentLoadStateByType.set({
|
||||
live: 'ready',
|
||||
vod: 'ready',
|
||||
series: 'ready',
|
||||
});
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
@@ -277,6 +452,67 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not rehydrate cached offline content when switching categories in a ready section', async () => {
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`;
|
||||
currentPlaylist.set(XTREAM_PLAYLIST);
|
||||
playlistId.set(PLAYLIST_ID);
|
||||
portalStatus.set('unavailable');
|
||||
isContentInitialized.set(true);
|
||||
contentLoadStateByType.set({
|
||||
live: 'idle',
|
||||
vod: 'ready',
|
||||
series: 'idle',
|
||||
});
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.setSelectedContentType).toHaveBeenCalledWith('vod');
|
||||
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(101);
|
||||
expect(selectedCategoryId()).toBe(101);
|
||||
expect(xtreamStore.hasUsableOfflineCache).not.toHaveBeenCalled();
|
||||
expect(xtreamStore.hydrateCachedContent).not.toHaveBeenCalled();
|
||||
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hydrates an offline cached type once and skips later category hydrations', async () => {
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/series`;
|
||||
currentPlaylist.set(XTREAM_PLAYLIST);
|
||||
playlistId.set(PLAYLIST_ID);
|
||||
portalStatus.set('unavailable');
|
||||
isContentInitialized.set(true);
|
||||
hasUsableOfflineCache = true;
|
||||
contentLoadStateByType.set({
|
||||
live: 'idle',
|
||||
vod: 'ready',
|
||||
series: 'idle',
|
||||
});
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalledWith(
|
||||
'series'
|
||||
);
|
||||
expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith(
|
||||
'series'
|
||||
);
|
||||
expect(contentLoadStateByType().series).toBe('ready');
|
||||
|
||||
xtreamStore.hasUsableOfflineCache.mockClear();
|
||||
xtreamStore.hydrateCachedContent.mockClear();
|
||||
xtreamStore.setSelectedCategory.mockClear();
|
||||
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/series/303`;
|
||||
routerEvents.next(new NavigationEnd(1, router.url, router.url));
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(303);
|
||||
expect(xtreamStore.hasUsableOfflineCache).not.toHaveBeenCalled();
|
||||
expect(xtreamStore.hydrateCachedContent).not.toHaveBeenCalled();
|
||||
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['expired', 'inactive', 'unavailable'] as const)(
|
||||
'blocks %s portals before import-driven initialization starts',
|
||||
async (status) => {
|
||||
@@ -296,7 +532,54 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('allows unavailable portals to initialize cached content', async () => {
|
||||
it.each(['expired', 'inactive', 'unavailable'] as const)(
|
||||
'hydrates cached content for %s portals',
|
||||
async (status) => {
|
||||
hasUsableOfflineCache = true;
|
||||
activePlaylist.set(UPDATED_ACTIVE_PLAYLIST);
|
||||
xtreamStore.checkPortalStatus.mockImplementation(async () => {
|
||||
portalStatus.set(status);
|
||||
return status;
|
||||
});
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
|
||||
expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalledWith(
|
||||
'vod'
|
||||
);
|
||||
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(
|
||||
null
|
||||
);
|
||||
expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith(
|
||||
'vod'
|
||||
);
|
||||
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps active cached playlists on the normal initialization path', async () => {
|
||||
hasUsableOfflineCache = true;
|
||||
activePlaylist.set(UPDATED_ACTIVE_PLAYLIST);
|
||||
xtreamStore.checkPortalStatus.mockImplementation(async () => {
|
||||
portalStatus.set('active');
|
||||
return 'active';
|
||||
});
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
|
||||
expect(xtreamStore.hasUsableOfflineCache).not.toHaveBeenCalled();
|
||||
expect(xtreamStore.initializeContent).toHaveBeenCalled();
|
||||
expect(xtreamStore.hydrateCachedContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hydrates cached search routes when any Xtream content is persisted', async () => {
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/search`;
|
||||
hasUsableOfflineCache = true;
|
||||
activePlaylist.set(UPDATED_ACTIVE_PLAYLIST);
|
||||
xtreamStore.checkPortalStatus.mockImplementation(async () => {
|
||||
@@ -309,11 +592,16 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.checkPortalStatus).toHaveBeenCalled();
|
||||
expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalled();
|
||||
expect(xtreamStore.hasUsableOfflineCache).toHaveBeenCalledWith(
|
||||
'search'
|
||||
);
|
||||
expect(xtreamStore.setContentInitBlockReason).toHaveBeenCalledWith(
|
||||
null
|
||||
);
|
||||
expect(xtreamStore.initializeContent).toHaveBeenCalled();
|
||||
expect(xtreamStore.hydrateCachedContent).toHaveBeenCalledWith(
|
||||
'search'
|
||||
);
|
||||
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rebootstraps the current Xtream playlist when its connection details change', async () => {
|
||||
|
||||
@@ -7,17 +7,24 @@ import {
|
||||
Provider,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { filter } from 'rxjs';
|
||||
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PortalRailSection } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
XtreamCachedContentScope,
|
||||
PortalStatusType,
|
||||
XtreamPlaylistData,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
|
||||
function normalizeOptionalConnectionValue(
|
||||
value: string | null | undefined
|
||||
): string | null {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function toXtreamPlaylistData(
|
||||
playlist: PlaylistMeta | null
|
||||
): XtreamPlaylistData | null {
|
||||
@@ -30,6 +37,10 @@ function toXtreamPlaylistData(
|
||||
return null;
|
||||
}
|
||||
|
||||
const userAgent = normalizeOptionalConnectionValue(playlist.userAgent);
|
||||
const referrer = normalizeOptionalConnectionValue(playlist.referrer);
|
||||
const origin = normalizeOptionalConnectionValue(playlist.origin);
|
||||
|
||||
return {
|
||||
id: playlist._id,
|
||||
name: playlist.title || playlist.filename || 'Untitled playlist',
|
||||
@@ -39,9 +50,9 @@ function toXtreamPlaylistData(
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
type: 'xtream',
|
||||
userAgent: playlist.userAgent,
|
||||
referrer: playlist.referrer,
|
||||
origin: playlist.origin,
|
||||
...(userAgent ? { userAgent } : {}),
|
||||
...(referrer ? { referrer } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +79,35 @@ function toContentInitBlockReason(
|
||||
}
|
||||
}
|
||||
|
||||
function toCachedContentScope(
|
||||
section: PortalRailSection | null
|
||||
): XtreamCachedContentScope | null {
|
||||
switch (section) {
|
||||
case 'live':
|
||||
case 'vod':
|
||||
case 'series':
|
||||
case 'search':
|
||||
case 'recently-added':
|
||||
return section;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getXtreamRouteTarget(url: string): {
|
||||
playlistId: string | null;
|
||||
section: PortalRailSection | null;
|
||||
} {
|
||||
const match = url.match(
|
||||
/^\/workspace\/xtreams\/([^/?]+)(?:\/([^/?]+))?/
|
||||
);
|
||||
|
||||
return {
|
||||
playlistId: match?.[1] ?? null,
|
||||
section: (match?.[2] as PortalRailSection | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasPlaylistConnectionChanges(
|
||||
currentPlaylist: XtreamPlaylistData | null,
|
||||
nextPlaylist: XtreamPlaylistData | null
|
||||
@@ -76,12 +116,20 @@ function hasPlaylistConnectionChanges(
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentUserAgent = currentPlaylist.userAgent ?? null;
|
||||
const nextUserAgent = nextPlaylist.userAgent ?? null;
|
||||
const currentReferrer = currentPlaylist.referrer ?? null;
|
||||
const nextReferrer = nextPlaylist.referrer ?? null;
|
||||
const currentOrigin = currentPlaylist.origin ?? null;
|
||||
const nextOrigin = nextPlaylist.origin ?? null;
|
||||
const currentUserAgent = normalizeOptionalConnectionValue(
|
||||
currentPlaylist.userAgent
|
||||
);
|
||||
const nextUserAgent = normalizeOptionalConnectionValue(
|
||||
nextPlaylist.userAgent
|
||||
);
|
||||
const currentReferrer = normalizeOptionalConnectionValue(
|
||||
currentPlaylist.referrer
|
||||
);
|
||||
const nextReferrer = normalizeOptionalConnectionValue(nextPlaylist.referrer);
|
||||
const currentOrigin = normalizeOptionalConnectionValue(
|
||||
currentPlaylist.origin
|
||||
);
|
||||
const nextOrigin = normalizeOptionalConnectionValue(nextPlaylist.origin);
|
||||
|
||||
return (
|
||||
currentPlaylist.serverUrl !== nextPlaylist.serverUrl ||
|
||||
@@ -164,20 +212,36 @@ export class XtreamWorkspaceRouteSession {
|
||||
});
|
||||
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter(
|
||||
(event): event is NavigationEnd =>
|
||||
event instanceof NavigationEnd
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.scheduleSyncRouteContext();
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((event) => {
|
||||
if (event instanceof NavigationStart) {
|
||||
this.prepareTargetPlaylistLoading(event.url);
|
||||
}
|
||||
|
||||
if (event instanceof NavigationEnd) {
|
||||
this.scheduleSyncRouteContext();
|
||||
}
|
||||
});
|
||||
|
||||
this.scheduleSyncRouteContext();
|
||||
}
|
||||
|
||||
private prepareTargetPlaylistLoading(url: string): void {
|
||||
const target = getXtreamRouteTarget(url);
|
||||
|
||||
if (
|
||||
!target.playlistId ||
|
||||
target.playlistId === this.xtreamStore.playlistId() ||
|
||||
!isImportDrivenSection(target.section)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.xtreamStore.prepareContentLoading(
|
||||
toCachedContentScope(target.section)
|
||||
);
|
||||
}
|
||||
|
||||
private scheduleSyncRouteContext(): void {
|
||||
if (this.syncInFlight) {
|
||||
this.syncPending = true;
|
||||
@@ -218,23 +282,31 @@ export class XtreamWorkspaceRouteSession {
|
||||
storePlaylistId,
|
||||
currentPlaylist
|
||||
);
|
||||
const cacheScope = toCachedContentScope(routeSection);
|
||||
let portalStatus = this.xtreamStore.portalStatus();
|
||||
let didBootstrapPlaylist = false;
|
||||
let canUseCachedContent = false;
|
||||
let section = routeSection;
|
||||
|
||||
if (playlistId && shouldBootstrapPlaylist) {
|
||||
this.xtreamStore.resetStore(playlistId);
|
||||
didBootstrapPlaylist = true;
|
||||
|
||||
this.xtreamStore.setCurrentPlaylist(routePlaylist);
|
||||
section = this.syncRouteState(routeSection);
|
||||
if (isImportDrivenSection(section)) {
|
||||
this.xtreamStore.prepareContentLoading(cacheScope);
|
||||
}
|
||||
|
||||
await this.xtreamStore.fetchXtreamPlaylist();
|
||||
portalStatus = await this.xtreamStore.checkPortalStatus();
|
||||
const hasUsableOfflineCache =
|
||||
portalStatus === 'unavailable'
|
||||
? await this.xtreamStore.hasUsableOfflineCache()
|
||||
canUseCachedContent =
|
||||
portalStatus !== 'active' && cacheScope
|
||||
? this.xtreamStore.isCachedContentScopeReady(cacheScope) ||
|
||||
(await this.xtreamStore.hasUsableOfflineCache(cacheScope))
|
||||
: false;
|
||||
const nextBlockReason =
|
||||
portalStatus === 'unavailable' && hasUsableOfflineCache
|
||||
canUseCachedContent
|
||||
? null
|
||||
: toContentInitBlockReason(portalStatus);
|
||||
const currentBlockReason =
|
||||
@@ -248,24 +320,33 @@ export class XtreamWorkspaceRouteSession {
|
||||
}
|
||||
}
|
||||
|
||||
const section = this.syncRouteState(routeSection);
|
||||
if (!didBootstrapPlaylist) {
|
||||
section = this.syncRouteState(routeSection);
|
||||
}
|
||||
|
||||
const canUseOfflineCache =
|
||||
portalStatus === 'unavailable'
|
||||
? await this.xtreamStore.hasUsableOfflineCache()
|
||||
: false;
|
||||
if (
|
||||
portalStatus !== 'active' &&
|
||||
!canUseCachedContent &&
|
||||
cacheScope
|
||||
) {
|
||||
canUseCachedContent =
|
||||
this.xtreamStore.isCachedContentScopeReady(cacheScope) ||
|
||||
(await this.xtreamStore.hasUsableOfflineCache(cacheScope));
|
||||
}
|
||||
|
||||
if (
|
||||
isImportDrivenSection(section) &&
|
||||
portalStatus !== 'active' &&
|
||||
!canUseOfflineCache
|
||||
!canUseCachedContent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.initializeCurrentSectionContent(
|
||||
section,
|
||||
didBootstrapPlaylist
|
||||
didBootstrapPlaylist,
|
||||
canUseCachedContent,
|
||||
cacheScope
|
||||
);
|
||||
}
|
||||
|
||||
@@ -289,7 +370,9 @@ export class XtreamWorkspaceRouteSession {
|
||||
|
||||
private async initializeCurrentSectionContent(
|
||||
section: PortalRailSection | null,
|
||||
didBootstrapPlaylist: boolean
|
||||
didBootstrapPlaylist: boolean,
|
||||
canUseCachedContent: boolean,
|
||||
cacheScope: XtreamCachedContentScope | null
|
||||
): Promise<void> {
|
||||
const playlist = this.xtreamStore.currentPlaylist();
|
||||
const playlistId = this.xtreamStore.playlistId();
|
||||
@@ -298,9 +381,26 @@ export class XtreamWorkspaceRouteSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isImportDrivenSection(section) && canUseCachedContent) {
|
||||
if (
|
||||
cacheScope &&
|
||||
!this.xtreamStore.isCachedContentScopeReady(cacheScope)
|
||||
) {
|
||||
await this.xtreamStore.hydrateCachedContent(cacheScope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionLoadState =
|
||||
section === 'live' || section === 'vod' || section === 'series'
|
||||
? this.xtreamStore.contentLoadStateByType()[section]
|
||||
: null;
|
||||
|
||||
if (
|
||||
isImportDrivenSection(section) &&
|
||||
(didBootstrapPlaylist || !this.xtreamStore.isContentInitialized())
|
||||
(didBootstrapPlaylist ||
|
||||
!this.xtreamStore.isContentInitialized() ||
|
||||
sectionLoadState !== 'ready')
|
||||
) {
|
||||
await this.xtreamStore.initializeContent();
|
||||
}
|
||||
|
||||
+29
-6
@@ -31,6 +31,7 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
const xtreamSelectedTypeContentState =
|
||||
signal<XtreamContentLoadState>('loading');
|
||||
const xtreamImportPhase = signal<string | null>('loading-live');
|
||||
const xtreamIsImporting = signal(true);
|
||||
const xtreamIsLoadingCategories = signal(false);
|
||||
|
||||
const xtreamStore = {
|
||||
@@ -44,6 +45,7 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
selectedTypeCountsReady: computed(
|
||||
() => xtreamSelectedTypeContentState() === 'ready'
|
||||
),
|
||||
isImporting: xtreamIsImporting,
|
||||
currentImportPhase: xtreamImportPhase,
|
||||
isLoadingCategories: xtreamIsLoadingCategories,
|
||||
setSelectedItem: jest.fn(),
|
||||
@@ -75,6 +77,7 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
xtreamSelectedCategoryId.set(null);
|
||||
xtreamSelectedTypeContentState.set('loading');
|
||||
xtreamImportPhase.set('loading-live');
|
||||
xtreamIsImporting.set(true);
|
||||
xtreamIsLoadingCategories.set(false);
|
||||
xtreamStore.setSelectedItem.mockClear();
|
||||
xtreamStore.setSelectedCategory.mockClear();
|
||||
@@ -137,9 +140,9 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
const status = fixture.nativeElement.querySelector(
|
||||
'.context-inline-status'
|
||||
) as HTMLElement | null;
|
||||
const manageButton = fixture.nativeElement.querySelector(
|
||||
'.context-header__action'
|
||||
) as HTMLButtonElement | null;
|
||||
const manageButton = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.context-header__action')
|
||||
).at(-1) as HTMLButtonElement | undefined;
|
||||
|
||||
expect(countPlaceholders).toHaveLength(2);
|
||||
expect(categoryButtons.every((button) => button.disabled)).toBe(true);
|
||||
@@ -156,6 +159,26 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps local xtream loading states quiet when no import is running', () => {
|
||||
fixture.componentRef.setInput('section', 'vod');
|
||||
xtreamIsImporting.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const countPlaceholders = fixture.nativeElement.querySelectorAll(
|
||||
'.item-count--loading'
|
||||
);
|
||||
const categoryButtons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.category-item')
|
||||
) as HTMLButtonElement[];
|
||||
const status = fixture.nativeElement.querySelector(
|
||||
'.context-inline-status'
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(countPlaceholders).toHaveLength(2);
|
||||
expect(categoryButtons.every((button) => button.disabled)).toBe(true);
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
|
||||
it('shows real counts and enables navigation once the selected xtream type is ready', () => {
|
||||
fixture.componentRef.setInput('section', 'vod');
|
||||
xtreamSelectedTypeContentState.set('ready');
|
||||
@@ -174,9 +197,9 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
const categoryButtons = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.category-item')
|
||||
) as HTMLButtonElement[];
|
||||
const manageButton = fixture.nativeElement.querySelector(
|
||||
'.context-header__action'
|
||||
) as HTMLButtonElement | null;
|
||||
const manageButton = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.context-header__action')
|
||||
).at(-1) as HTMLButtonElement | undefined;
|
||||
|
||||
expect(countTexts).toEqual(['3', '0']);
|
||||
expect(categoryButtons.every((button) => !button.disabled)).toBe(true);
|
||||
|
||||
+2
@@ -81,6 +81,7 @@ export class WorkspaceContextPanelComponent {
|
||||
this.xtreamStore.selectedTypeContentReady;
|
||||
readonly xtreamSelectedTypeCountsReady =
|
||||
this.xtreamStore.selectedTypeCountsReady;
|
||||
readonly isXtreamImporting = this.xtreamStore.isImporting;
|
||||
readonly xtreamImportPhase = this.xtreamStore.currentImportPhase;
|
||||
readonly isXtreamCategoryLoading = computed(
|
||||
() =>
|
||||
@@ -106,6 +107,7 @@ export class WorkspaceContextPanelComponent {
|
||||
if (
|
||||
!this.isXtreamCategories() ||
|
||||
this.isXtreamCategoryLoading() ||
|
||||
!this.isXtreamImporting() ||
|
||||
this.xtreamSelectedTypeContentState() !== 'loading'
|
||||
) {
|
||||
return '';
|
||||
|
||||
Reference in new issue
Block a user