From cbedb2269884b93e2cb234f049f988117fb6b883 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Apr 2026 18:54:58 +0200 Subject: [PATCH] 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 --- docs/architecture/workspace-shell.md | 7 +- .../electron-xtream-data-source.ts | 17 + .../data-sources/pwa-xtream-data-source.ts | 18 + .../xtream-data-source.interface.ts | 18 + .../features/with-content.feature.spec.ts | 348 +++++++++++++++-- .../stores/features/with-content.feature.ts | 360 ++++++++++++++++-- .../data-access/src/lib/xtream-state.ts | 4 + .../live-stream-layout.component.html | 18 +- .../live-stream-layout.component.scss | 11 + .../live-stream-layout.component.spec.ts | 21 +- .../live-stream-layout.component.ts | 2 + ...am-workspace-route-session.service.spec.ts | 304 ++++++++++++++- .../xtream-workspace-route-session.service.ts | 166 ++++++-- .../workspace-context-panel.component.spec.ts | 35 +- .../workspace-context-panel.component.ts | 2 + 15 files changed, 1211 insertions(+), 120 deletions(-) diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index a6eec9f11..5bdbb15b8 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -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 diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index a236c5e28..cfccd8ae1 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -223,6 +223,16 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { return this.dbService.getAllXtreamCategories(playlistId, type); } + async getCachedCategories( + playlistId: string, + type: CategoryType + ): Promise { + 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 { + return this.dbService.getXtreamContent(playlistId, type); + } + async saveContent( playlistId: string, streams: diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index e1628cab5..115865ef2 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -171,6 +171,15 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return []; } + async getCachedCategories( + playlistId: string, + type: CategoryType + ): Promise { + 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 { + void playlistId; + void type; + return []; + } + async saveContent( playlistId: string, streams: diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index de9b5e181..3041999d6 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -182,6 +182,15 @@ export interface IXtreamDataSource { options?: XtreamOperationOptions ): Promise; + /** + * 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; + /** * 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; + /** * Save content in bulk */ diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index 67a395879..80207ca94 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -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 { @@ -84,7 +85,9 @@ describe('withContent import state', () => { let store: InstanceType; 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(); + const cachedContent = createDeferred(); + 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(); + const cachedContent = createDeferred(); + 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(); + const cachedSeriesContent = createDeferred(); + 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'); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index dceaca409..bbd678193 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -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; + 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 | null = null; + let cachedHydrationGeneration = 0; + const activeCachedHydrationPromises = new Map< + string, + Promise + >(); + + 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 = (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 => { - 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 => { + 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 => { + const types = getTypesForCacheScope(scope); + markContentScopeLoading(scope, { + preserveInitialized: store.isContentInitialized(), + }); + + let cachedEntries: Array<{ + type: ContentType; + categories: Awaited< + ReturnType + >; + content: Awaited< + ReturnType + >; + }>; + + 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 = { + 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( + entry.content + ); + break; + case 'vod': + updates.vodCategories = entry.categories; + updates.vodStreams = + asCachedContent( + entry.content + ); + break; + case 'series': + updates.serialCategories = entry.categories; + updates.serialStreams = + asCachedContent( + entry.content + ); + break; + } + } + + updates.contentLoadStateByType = nextLoadStates; + return updates; + }); + }; + + const hydrateCachedContentForScope = async ( + scope?: XtreamCachedContentScope | null + ): Promise => { + 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 { + async hasUsableOfflineCache( + scope?: XtreamCachedContentScope | null + ): Promise { 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 { + await hydrateCachedContentForScope(scope); }, async retryContentInitialization(): Promise { @@ -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); }, }; diff --git a/libs/portal/xtream/data-access/src/lib/xtream-state.ts b/libs/portal/xtream/data-access/src/lib/xtream-state.ts index 0f79c9b31..8410e8262 100644 --- a/libs/portal/xtream/data-access/src/lib/xtream-state.ts +++ b/libs/portal/xtream/data-access/src/lib/xtream-state.ts @@ -12,6 +12,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, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 3497dfc3b..11083ae13 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -68,10 +68,20 @@ }
@if (!selectedCategoryId()) { - + @if (isSelectedTypeContentLoading()) { +
+ +
+ } @else { + + } } @else if (streamUrl) { @if (isEmbeddedPlayer) {
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss index 6cddd46bf..7672d98be 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss @@ -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; diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 4112ab276..c5df61483 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -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: '
', }) class StubPortalChannelsListComponent { readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server'); @@ -120,6 +120,7 @@ describe('LiveStreamLayoutComponent', () => { const epgItems = signal([]); const currentEpgItem = signal(null); const isLoadingEpg = signal(false); + const selectedTypeContentLoading = signal(false); const selectedCategoryId = signal(1); const selectedContentType = signal<'live' | 'vod' | 'series'>('live'); const selectedItem = signal(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( diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index dee7d6815..01bc0a03b 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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('server'); diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts index 60d206c52..840edc39f 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts @@ -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 { - 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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + describe('XtreamWorkspaceRouteSession', () => { - const routerEvents = new Subject(); + const routerEvents = new Subject(); const routeProvider = signal<'xtreams' | null>('xtreams'); const routePlaylistId = signal(PLAYLIST_ID); const activePlaylist = signal(ACTIVE_PLAYLIST); @@ -66,6 +80,13 @@ describe('XtreamWorkspaceRouteSession', () => { const selectedContentType = signal<'live' | 'vod' | 'series'>('vod'); const selectedCategoryId = signal(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(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(); + 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 () => { diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index 329c5e8d0..e35c67f72 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -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 { 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(); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 05db31d58..1f0a2e561 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -31,6 +31,7 @@ describe('WorkspaceContextPanelComponent', () => { const xtreamSelectedTypeContentState = signal('loading'); const xtreamImportPhase = signal('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); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 889e1ef43..5ec9fd0ff 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -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 '';