diff --git a/libs/portal/xtream/data-access/jest.config.ts b/libs/portal/xtream/data-access/jest.config.ts new file mode 100644 index 000000000..86b22f075 --- /dev/null +++ b/libs/portal/xtream/data-access/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portal-xtream-data-access', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/portal/xtream/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/portal/xtream/data-access/project.json b/libs/portal/xtream/data-access/project.json index e4283185d..5fc7ca99b 100644 --- a/libs/portal/xtream/data-access/project.json +++ b/libs/portal/xtream/data-access/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:portal", "domain:xtream", "type:data-access"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/portal/xtream/data-access/jest.config.ts", + "tsConfig": "libs/portal/xtream/data-access/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } 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 88259e6f1..0c28be8d7 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 @@ -19,6 +19,7 @@ import { IXtreamDataSource, mapCategoryTypeToDbType, ProgressCallback, + XtreamOperationOptions, XtreamCategoryFromDb, XtreamContentItem, XtreamPlaylistData, @@ -195,7 +196,8 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { credentials: XtreamCredentials, type: StreamType, onProgress?: (count: number) => void, - onTotal?: (total: number) => void + onTotal?: (total: number) => void, + options?: XtreamOperationOptions ): Promise { // Fetch from DB directly — avoids a separate 'has' round-trip. // An empty result means the cache is cold; proceed to fetch from API. @@ -221,7 +223,8 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { | XtreamVodStream[] | XtreamSerieItem[], type, - onProgress + onProgress, + options ); } @@ -237,13 +240,15 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { | XtreamSerieItem[] | XtreamContentItem[], type: 'live' | 'movie' | 'series', - onProgress?: ProgressCallback + onProgress?: ProgressCallback, + options?: XtreamOperationOptions ): Promise { return this.dbService.saveXtreamContent( playlistId, streams, type, - onProgress + onProgress, + options ); } @@ -407,12 +412,14 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { async restoreUserData( playlistId: string, favoritedXtreamIds: number[], - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] + recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[], + options?: XtreamOperationOptions ): Promise { await this.dbService.restoreXtreamUserData( playlistId, favoritedXtreamIds, - recentlyViewedXtreamIds + recentlyViewedXtreamIds, + options ); } } 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 d5d811b8b..73a482756 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 @@ -17,6 +17,7 @@ import { DbCategoryType, IXtreamDataSource, ProgressCallback, + XtreamOperationOptions, XtreamCategoryFromDb, XtreamContentItem, XtreamPlaylistData, @@ -204,8 +205,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { credentials: XtreamCredentials, type: StreamType, onProgress?: (count: number) => void, - onTotal?: (total: number) => void + onTotal?: (total: number) => void, + options?: XtreamOperationOptions ): Promise { + void options; const cacheKey = `${playlistId}-${type}-content`; // Check in-memory cache first @@ -242,8 +245,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { | XtreamSerieItem[] | XtreamContentItem[], type: 'live' | 'movie' | 'series', - onProgress?: ProgressCallback + onProgress?: ProgressCallback, + options?: XtreamOperationOptions ): Promise { + void options; // In PWA mode, we just cache in memory const cacheKey = `${playlistId}-${type}-content`; this.contentCache.set(cacheKey, streams); @@ -646,8 +651,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async restoreUserData( playlistId: string, favoritedXtreamIds: number[], - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] + recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[], + options?: XtreamOperationOptions ): Promise { + void options; // Restore favorites const favorites = this.getFavoritesFromStorage(); favorites[playlistId] = favoritedXtreamIds; 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 32770b627..abd266093 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 @@ -6,6 +6,7 @@ import { XtreamSerieItem, XtreamVodStream, } from 'shared-interfaces'; +import type { DbOperationEvent } from 'services'; import { CategoryType, StreamType, @@ -116,6 +117,11 @@ export function mapStreamTypeToDbType( */ export type ProgressCallback = (count: number) => void; +export interface XtreamOperationOptions { + operationId?: string; + onEvent?: (event: DbOperationEvent) => void; +} + /** * Abstract interface for Xtream data source. * Allows different implementations for Electron (DB-first) and PWA (API-only). @@ -214,7 +220,8 @@ export interface IXtreamDataSource { credentials: XtreamCredentials, type: StreamType, onProgress?: ProgressCallback, - onTotal?: (total: number) => void + onTotal?: (total: number) => void, + options?: XtreamOperationOptions ): Promise< | XtreamLiveStream[] | XtreamVodStream[] @@ -233,7 +240,8 @@ export interface IXtreamDataSource { | XtreamSerieItem[] | XtreamContentItem[], type: 'live' | 'movie' | 'series', - onProgress?: ProgressCallback + onProgress?: ProgressCallback, + options?: XtreamOperationOptions ): Promise; // ========================================================================= @@ -389,7 +397,8 @@ export interface IXtreamDataSource { restoreUserData( playlistId: string, favoritedXtreamIds: number[], - recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[] + recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[], + options?: XtreamOperationOptions ): Promise; } 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 new file mode 100644 index 000000000..ac74f2128 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -0,0 +1,256 @@ +import { TestBed } from '@angular/core/testing'; +import { signalStore, withState } from '@ngrx/signals'; +import { DatabaseService } from 'services'; +import { + XTREAM_DATA_SOURCE, + XtreamPlaylistData, +} from '../../data-sources/xtream-data-source.interface'; +import { withContent } from './with-content.feature'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +type ContentType = 'live' | 'movie' | 'series'; + +const PLAYLIST: XtreamPlaylistData = { + id: 'playlist-1', + name: 'Test Xtream', + serverUrl: 'http://localhost:8080', + username: 'demo', + password: 'secret', + type: 'xtream', +}; + +const TestContentStore = signalStore( + withState({ + playlistId: PLAYLIST.id, + currentPlaylist: PLAYLIST, + }), + withContent() +); + +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 }; +} + +function createAbortError(): Error { + const error = new Error('Import cancelled'); + error.name = 'AbortError'; + return error; +} + +describe('withContent import state', () => { + let store: InstanceType; + let dataSource: { + getCategories: jest.Mock; + getContent: jest.Mock; + restoreUserData: jest.Mock; + }; + let databaseService: { + cancelOperation: jest.Mock; + supportsDbOperationCancellation: jest.Mock; + }; + + beforeEach(() => { + localStorage.clear(); + + dataSource = { + getCategories: jest.fn().mockResolvedValue([]), + getContent: jest.fn(), + restoreUserData: jest.fn().mockResolvedValue(undefined), + }; + databaseService = { + cancelOperation: jest.fn().mockResolvedValue(true), + supportsDbOperationCancellation: jest.fn().mockReturnValue(true), + }; + + TestBed.configureTestingModule({ + providers: [ + TestContentStore, + { + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, + }, + { + provide: DatabaseService, + useValue: databaseService, + }, + ], + }); + + store = TestBed.inject(TestContentStore); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('tracks aggregated import progress while content is loading', async () => { + const pending = { + live: createDeferred(), + movie: createDeferred(), + series: createDeferred(), + }; + const optionsByType = new Map< + ContentType, + { onEvent?: (event: any) => void } | undefined + >(); + const totals: Record = { + live: 2, + movie: 3, + series: 4, + }; + + dataSource.getContent.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: ContentType, + onProgress?: (count: number) => void, + onTotal?: (total: number) => void, + options?: { onEvent?: (event: any) => void } + ) => { + optionsByType.set(type, options); + onTotal?.(totals[type]); + options?.onEvent?.({ + operation: 'save-content', + operationId: `${type}-op`, + status: 'started', + phase: 'saving-content', + }); + onProgress?.(1); + options?.onEvent?.({ + operation: 'save-content', + operationId: `${type}-op`, + status: 'progress', + phase: 'saving-content', + current: 1, + total: totals[type], + }); + + return pending[type].promise; + } + ); + + const initialization = store.initializeContent(); + await Promise.resolve(); + + expect(store.isImporting()).toBe(true); + expect(store.importCount()).toBe(3); + expect(store.itemsToImport()).toBe(9); + expect(store.activeImportOperationIds().sort()).toEqual([ + 'live-op', + 'movie-op', + 'series-op', + ]); + expect(store.importPhase()).toBe('saving-content'); + + (['live', 'movie', 'series'] as ContentType[]).forEach((type) => { + optionsByType.get(type)?.onEvent?.({ + operation: 'save-content', + operationId: `${type}-op`, + status: 'completed', + phase: 'saving-content', + current: totals[type], + total: totals[type], + }); + pending[type].resolve([]); + }); + + await initialization; + + expect(store.isImporting()).toBe(false); + expect(store.isContentInitialized()).toBe(true); + expect(store.activeImportOperationIds()).toEqual([]); + expect(store.importPhase()).toBeNull(); + expect(store.importCount()).toBe(0); + expect(store.itemsToImport()).toBe(0); + }); + + it('cancels active imports and clears stale progress after worker aborts', async () => { + const pending = { + live: createDeferred(), + movie: createDeferred(), + series: createDeferred(), + }; + const optionsByType = new Map< + ContentType, + { onEvent?: (event: any) => void } | undefined + >(); + + dataSource.getContent.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: ContentType, + onProgress?: (count: number) => void, + onTotal?: (total: number) => void, + options?: { onEvent?: (event: any) => void } + ) => { + optionsByType.set(type, options); + onTotal?.(5); + options?.onEvent?.({ + operation: 'save-content', + operationId: `${type}-op`, + status: 'started', + phase: 'saving-content', + }); + onProgress?.(2); + options?.onEvent?.({ + operation: 'save-content', + operationId: `${type}-op`, + status: 'progress', + phase: 'saving-content', + current: 2, + total: 5, + }); + + return pending[type].promise; + } + ); + + const initialization = store.initializeContent(); + await Promise.resolve(); + + await store.cancelImport(); + + expect(store.isCancellingImport()).toBe(true); + expect(databaseService.cancelOperation).toHaveBeenCalledTimes(3); + expect(databaseService.cancelOperation).toHaveBeenCalledWith('live-op'); + expect(databaseService.cancelOperation).toHaveBeenCalledWith('movie-op'); + expect(databaseService.cancelOperation).toHaveBeenCalledWith('series-op'); + + (['live', 'movie', 'series'] as ContentType[]).forEach((type) => { + optionsByType.get(type)?.onEvent?.({ + operation: 'save-content', + operationId: `${type}-op`, + status: 'cancelled', + phase: 'saving-content', + }); + pending[type].reject(createAbortError()); + }); + + await expect(initialization).resolves.toBeUndefined(); + + expect(store.isImporting()).toBe(false); + expect(store.isCancellingImport()).toBe(false); + expect(store.isContentInitialized()).toBe(false); + expect(store.activeImportOperationIds()).toEqual([]); + expect(store.importPhase()).toBeNull(); + expect(store.importCount()).toBe(0); + expect(store.itemsToImport()).toBe(0); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index a898d63c6..5398ab3ca 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 @@ -13,6 +13,11 @@ import { XtreamVodStream, } from 'shared-interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { + DatabaseService, + DbOperationEvent, + isDbAbortError, +} from 'services'; import { XTREAM_DATA_SOURCE, XtreamCategoryFromDb, @@ -33,8 +38,11 @@ export interface ContentState { isLoadingCategories: boolean; isLoadingContent: boolean; isImporting: boolean; + isCancellingImport: boolean; importCount: number; + importPhase: string | null; itemsToImport: number; + activeImportOperationIds: string[]; isContentInitialized: boolean; } @@ -51,8 +59,11 @@ const initialContentState: ContentState = { isLoadingCategories: false, isLoadingContent: false, isImporting: false, + isCancellingImport: false, importCount: 0, + importPhase: null, itemsToImport: 0, + activeImportOperationIds: [], isContentInitialized: false, }; @@ -121,13 +132,15 @@ export function withContent() { isContentImporting: computed(() => store.isImporting()), /** - * Check if content is already initialized + * Current import phase label key source */ - isContentInitialized: computed(() => store.isContentInitialized()), + currentImportPhase: computed(() => store.importPhase()), + })), withMethods((store) => { const dataSource = inject(XTREAM_DATA_SOURCE); + const databaseService = inject(DatabaseService); /** * Helper to get credentials from parent store @@ -156,6 +169,32 @@ export function withContent() { }; }; + const trackImportEvent = (event: DbOperationEvent): void => { + const operationId = event.operationId; + + patchState(store, (state) => ({ + importPhase: event.phase ?? state.importPhase, + activeImportOperationIds: + operationId == null + ? state.activeImportOperationIds + : event.status === 'completed' || + event.status === 'cancelled' || + event.status === 'error' + ? state.activeImportOperationIds.filter( + (id) => id !== operationId + ) + : state.activeImportOperationIds.includes( + operationId + ) + ? state.activeImportOperationIds + : [...state.activeImportOperationIds, operationId], + isCancellingImport: + event.status === 'cancelled' + ? false + : state.isCancellingImport, + })); + }; + return { /** * Fetch all categories in parallel @@ -192,8 +231,11 @@ export function withContent() { isLoadingCategories: false, }); } catch (error) { - logger.error('Error fetching categories', error); + if (!isDbAbortError(error)) { + logger.error('Error fetching categories', error); + } patchState(store, { isLoadingCategories: false }); + throw error; } }, @@ -227,21 +269,24 @@ export function withContent() { ctx.credentials, 'live', onProgress, - onTotal + onTotal, + { onEvent: trackImportEvent } ), dataSource.getContent( ctx.playlistId, ctx.credentials, 'movie', onProgress, - onTotal + onTotal, + { onEvent: trackImportEvent } ), dataSource.getContent( ctx.playlistId, ctx.credentials, 'series', onProgress, - onTotal + onTotal, + { onEvent: trackImportEvent } ), ]); @@ -252,8 +297,11 @@ export function withContent() { isLoadingContent: false, }); } catch (error) { - logger.error('Error fetching content', error); + if (!isDbAbortError(error)) { + logger.error('Error fetching content', error); + } patchState(store, { isLoadingContent: false }); + throw error; } }, @@ -271,8 +319,11 @@ export function withContent() { patchState(store, { isImporting: true, + isCancellingImport: false, importCount: 0, + importPhase: null, itemsToImport: 0, + activeImportOperationIds: [], }); try { @@ -294,21 +345,58 @@ export function withContent() { await dataSource.restoreUserData( ctx.playlistId, favoritedXtreamIds, - recentlyViewedXtreamIds + recentlyViewedXtreamIds, + { onEvent: trackImportEvent } ); localStorage.removeItem(restoreKey); } catch (err) { - logger.error('Error restoring user data', err); + if (!isDbAbortError(err)) { + logger.error( + 'Error restoring user data', + err + ); + } } } // Mark as initialized so next routings won't re-trigger it patchState(store, { isContentInitialized: true }); + } catch (error) { + if (!isDbAbortError(error)) { + logger.error('Error initializing content', error); + } } finally { - patchState(store, { isImporting: false }); + patchState(store, { + isImporting: false, + isCancellingImport: false, + importCount: 0, + importPhase: null, + itemsToImport: 0, + activeImportOperationIds: [], + }); } }, + async cancelImport(): Promise { + if ( + !databaseService.supportsDbOperationCancellation() || + store.activeImportOperationIds().length === 0 || + store.isCancellingImport() + ) { + return; + } + + patchState(store, { isCancellingImport: true }); + + await Promise.all( + store + .activeImportOperationIds() + .map((operationId) => + databaseService.cancelOperation(operationId) + ) + ); + }, + /** * Reload categories from database (after visibility changes) */ diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index 3362848b3..f86785286 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -130,8 +130,13 @@ export function withPortal() { } if (response.user_info.status === 'Active') { + if (!response.user_info.exp_date) { + patchState(store, { portalStatus: 'active' }); + return; + } + const expDate = new Date( - parseInt(response.user_info.exp_date) * 1000 + parseInt(response.user_info.exp_date, 10) * 1000 ); if (expDate < new Date()) { patchState(store, { portalStatus: 'expired' }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts index c3eaa2325..953c384ee 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts @@ -69,6 +69,7 @@ export function withSearch() { withMethods((store) => { const dataSource = inject(XTREAM_DATA_SOURCE); + let searchRequestVersion = 0; return { /** @@ -82,9 +83,13 @@ export function withSearch() { // Access parent store's playlistId (from withPortal) const storeAny = store as ParentSearchStoreLike; const playlistId = storeAny.playlistId?.(); + const requestVersion = ++searchRequestVersion; if (!playlistId || !searchTerm.trim()) { - patchState(store, { searchResults: [] }); + patchState(store, { + searchResults: [], + isSearching: false, + }); return []; } @@ -98,6 +103,10 @@ export function withSearch() { excludeHidden ); + if (requestVersion !== searchRequestVersion) { + return results; + } + patchState(store, { searchResults: results, isSearching: false, @@ -106,6 +115,11 @@ export function withSearch() { return results; } catch (error) { logger.error('Error searching content', error); + + if (requestVersion !== searchRequestVersion) { + return []; + } + patchState(store, { searchResults: [], isSearching: false, @@ -165,6 +179,7 @@ export function withSearch() { * Clear search results */ resetSearchResults(): void { + searchRequestVersion++; patchState(store, initialSearchState); }, }; diff --git a/libs/portal/xtream/data-access/src/test-setup.ts b/libs/portal/xtream/data-access/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/portal/xtream/data-access/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/portal/xtream/data-access/tsconfig.spec.json b/libs/portal/xtream/data-access/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/portal/xtream/data-access/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/portal/xtream/feature/jest.config.ts b/libs/portal/xtream/feature/jest.config.ts new file mode 100644 index 000000000..656602546 --- /dev/null +++ b/libs/portal/xtream/feature/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'portal-xtream-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/portal/xtream/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/portal/xtream/feature/project.json b/libs/portal/xtream/feature/project.json index e89836816..509357de9 100644 --- a/libs/portal/xtream/feature/project.json +++ b/libs/portal/xtream/feature/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:portal", "domain:xtream", "type:feature"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/portal/xtream/feature/jest.config.ts", + "tsConfig": "libs/portal/xtream/feature/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts index ecf6a3fe5..eb7179979 100644 --- a/libs/portal/xtream/feature/src/index.ts +++ b/libs/portal/xtream/feature/src/index.ts @@ -1,13 +1,11 @@ export * from './lib/account-info/account-info.component'; export * from './lib/category-management-dialog/category-management-dialog.component'; -export * from './lib/global-recently-viewed/global-recently-viewed.component'; export * from './lib/global-search-results/global-search-results.component'; export * from './lib/live-stream-layout/live-stream-layout.component'; export * from './lib/loading-overlay.component'; export * from './lib/player-dialog/player-dialog.component'; export * from './lib/portal-channels-list/portal-channels-list.component'; export * from './lib/recently-added/recently-added.component'; -export * from './lib/recently-viewed/recently-viewed.component'; export * from './lib/search-results/search-results.component'; export * from './lib/serial-details/serial-details.component'; export * from './lib/vod-details/vod-details-route.component'; diff --git a/libs/portal/xtream/feature/src/lib/global-recently-viewed/global-recently-viewed.component.ts b/libs/portal/xtream/feature/src/lib/global-recently-viewed/global-recently-viewed.component.ts deleted file mode 100644 index 349acb473..000000000 --- a/libs/portal/xtream/feature/src/lib/global-recently-viewed/global-recently-viewed.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component } from '@angular/core'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { RecentlyViewedComponent } from '../recently-viewed/recently-viewed.component'; - -@Component({ - selector: 'app-global-recently-viewed', - imports: [RecentlyViewedComponent], - providers: [XtreamStore], - template: '', -}) -export class GlobalRecentlyViewedComponent {} diff --git a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.html b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.html deleted file mode 100644 index 5cc6951f2..000000000 --- a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.html +++ /dev/null @@ -1,189 +0,0 @@ -@if (isGlobal) { -
-
-

Recently Viewed

-
- @if (recentItems()?.length) { - - } - -
-
- - @if (recentItems()?.length) { - -
-
-
- info_outline - Includes Xtream, Stalker, and M3U items -
-
- - Live - - - Movies - - - Series - -
-
- - Group by playlist - -
- - @if (groupByPlaylist()) { -
-

- Found {{ visibleRecentItems().length }} items across - {{ (getGroupedItems() | keyvalue).length }} playlists -

- @for (group of getGroupedItems() | keyvalue; track group.key) { -
-

- playlist_play - {{ group.key }} - ({{ $any(group).value.length }} items) -

-
- @for (item of $any(group).value; track item.id) { - - } -
-
- } -
- } @else { -
- @for (item of visibleRecentItems(); track item.id) { - - } -
- } - } @else { -
- history -

No recently viewed items

-
- } -
-} @else { - @if (isLiveCategory()) { - - - - - - - - - - - - - } @else { - - } -} diff --git a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.scss b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.scss deleted file mode 100644 index 198d892ba..000000000 --- a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.scss +++ /dev/null @@ -1,182 +0,0 @@ -@use '../../../../../shared/ui/src/lib/styles/content-grid' as grid; - -:host { - display: flex; - flex-direction: column; - overflow-y: auto; - height: 100%; - width: 100%; -} - -.recent-container { - padding: 20px; -} - -.header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - - h2 { - margin: 0; - font-size: 1.2rem; - font-weight: 650; - letter-spacing: -0.02em; - } - - .header-actions { - display: flex; - align-items: center; - gap: 8px; - } -} - -.recent-search { - display: flex; - align-items: center; - gap: 10px; - margin: 0 0 1rem; - padding: 0 12px; - height: 42px; - border-radius: 12px; - border: 1px solid var(--app-separator); - background: var(--mat-sys-surface-container-low); - - mat-icon { - opacity: 0.6; - font-size: 18px; - width: 18px; - height: 18px; - } - - input { - width: 100%; - border: 0; - background: transparent; - color: inherit; - font: inherit; - opacity: 0.95; - - &::placeholder { - opacity: 0.5; - } - } -} - -.items-grid { - @include grid.content-grid(140px, 20px); -} - -.no-items { - text-align: center; - padding: 40px; - - mat-icon { - font-size: 48px; - width: 48px; - height: 48px; - margin-bottom: 16px; - } -} - -.playlist-section { - .section-title { - margin: 0 0 1.5rem; - font-size: 0.94rem; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--mat-sys-on-surface); - padding: 0 8px; - } -} - -.playlist-group { - margin-bottom: 2rem; - - .playlist-title { - display: flex; - align-items: center; - gap: 8px; - margin: 0 0 1rem 0; - padding: 8px; - background: rgba(0, 0, 0, 0.05); - border-radius: 4px; - font-size: 1rem; - font-weight: 600; - letter-spacing: -0.01em; - - mat-icon { - opacity: 0.7; - } - - .item-count { - margin-left: auto; - font-size: 0.85rem; - opacity: 0.7; - } - } -} - -// Subheader row for global recently viewed (note + grouping toggle) -.global-recent-subheader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin: 0 0 1rem; - - .global-recent-subheader-left { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; - } - - .global-recent-note { - display: flex; - align-items: center; - gap: 6px; - font-size: 0.8rem; - opacity: 0.55; - - mat-icon { - font-size: 15px; - width: 15px; - height: 15px; - flex-shrink: 0; - } - } - - .global-recent-filters { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; - } - - ::ng-deep mat-checkbox { - flex-shrink: 0; - } -} - -:host-context(.global-search-overlay) { - // Prevent Electron drag-region from blocking click interactions. - app-region: no-drag; - - .recent-container { - background: var(--background); - height: 100vh; - } - - .header-actions, - .header-actions button { - app-region: no-drag; - } - - .playlist-group { - .playlist-title { - background: var(--surface); - } - } -} diff --git a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts deleted file mode 100644 index 465eda0b2..000000000 --- a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts +++ /dev/null @@ -1,565 +0,0 @@ -import { KeyValuePipe } from '@angular/common'; -import { - Component, - computed, - effect, - inject, - Optional, - signal, -} from '@angular/core'; - -import { FormsModule } from '@angular/forms'; -import { MatButton, MatIconButton } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatIcon } from '@angular/material/icon'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; -import { TranslatePipe } from '@ngx-translate/core'; -import { firstValueFrom } from 'rxjs'; -import { DatabaseService, PlaylistsService } from 'services'; -import { ContentCardComponent, FavoriteLayoutItem } from '@iptvnator/portal/shared/ui'; -import { - PortalCollectionLiveShellComponent, -} from '@iptvnator/portal/shared/ui'; -import { - PortalCollectionMode, - PortalCollectionShellComponent, - PortalCollectionShellLayout, -} from '@iptvnator/portal/shared/ui'; -import { - buildXtreamNavigationTarget, - getRecentItemNavigation, - isWorkspaceLayoutRoute, - queryParamSignal, -} from '@iptvnator/portal/shared/util'; -import { createPortalCollectionContext } from '@iptvnator/portal/shared/util'; -import { - buildStandardCollectionCategories, - filterCollectionBucket, -} from '@iptvnator/portal/shared/util'; -import { createLogger } from '@iptvnator/portal/shared/util'; -import { PortalCollectionContextService } from '@iptvnator/portal/shared/util'; -import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; -import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; - -const XTREAM_RECENT_LAYOUT: Omit< - PortalCollectionShellLayout, - 'showHeaderAction' -> = { - titleTranslationKey: 'PORTALS.SIDEBAR.RECENT', - removeTooltip: 'Remove from history', - emptyIcon: 'history', - headerActionIcon: 'delete_sweep', - headerActionTooltip: 'Clear history', -}; -const XTREAM_COLLECTION_LABELS = { - all: 'All', - movie: 'Movies', - live: 'Live TV', - series: 'Series', -}; -type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc'; -const XTREAM_RECENT_LIVE_SORT_STORAGE_KEY = - 'xtream-recent-live-channel-sort-mode'; - -type XtreamRecentContentType = 'live' | 'movie' | 'series'; - -interface XtreamRecentViewItem extends FavoriteLayoutItem { - readonly id: string | number; - readonly playlist_id: string; - readonly title: string; - readonly type: XtreamRecentContentType; - readonly xtream_id: string | number; -} - -interface XtreamRecentLiveItem { - readonly id?: string | number; - readonly poster_url?: string; - readonly stream_icon?: string; - readonly title?: string; - readonly xtream_id: number; -} - -function groupItemsByPlaylistName( - items: XtreamRecentViewItem[] -): Record { - return items.reduce>( - (groups, item) => { - const key = String(item.playlist_name); - if (!groups[key]) { - groups[key] = []; - } - groups[key].push(item); - return groups; - }, - {} - ); -} - -function isXtreamRecentViewItem( - item: FavoriteLayoutItem -): item is XtreamRecentViewItem { - return ( - (typeof item.id === 'number' || typeof item.id === 'string') && - typeof item.playlist_id === 'string' && - typeof item.title === 'string' && - typeof item.xtream_id !== 'undefined' && - (item.type === 'live' || - item.type === 'movie' || - item.type === 'series') - ); -} - -@Component({ - selector: 'app-recently-viewed', - imports: [ - ContentCardComponent, - FormsModule, - KeyValuePipe, - MatButton, - MatCheckboxModule, - MatIcon, - MatIconButton, - MatMenuModule, - MatTooltipModule, - PortalChannelsListComponent, - PortalCollectionLiveShellComponent, - PortalCollectionShellComponent, - TranslatePipe, - ], - providers: [], - templateUrl: './recently-viewed.component.html', - styleUrl: './recently-viewed.component.scss', -}) -export class RecentlyViewedComponent { - private static readonly GROUP_BY_STORAGE_KEY = - 'global-recent-group-by-playlist'; - private static readonly TYPE_FILTERS_STORAGE_KEY = - 'global-recent-type-filters'; - private static readonly MAX_FLAT_GLOBAL_ITEMS = 250; - - private xtreamStore = inject(XtreamStore); - private activatedRoute = inject(ActivatedRoute); - private router = inject(Router); - private readonly dbService = inject(DatabaseService); - private readonly playlistsService = inject(PlaylistsService); - private readonly portalPlayer = inject(PORTAL_PLAYER); - private dialogData = inject(MAT_DIALOG_DATA, { optional: true }); - private readonly logger = createLogger('XtreamRecentlyViewed'); - private readonly collectionCtx = inject(PortalCollectionContextService); - - readonly isGlobal = this.dialogData?.isGlobal ?? false; - readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute); - readonly nonGlobalLayout: PortalCollectionShellLayout = { - ...XTREAM_RECENT_LAYOUT, - showHeaderAction: !this.isWorkspaceLayout, - }; - readonly playlistSubtitle = 'Xtream Code'; - private readonly _groupByPlaylist = (() => { - const saved = localStorage.getItem( - RecentlyViewedComponent.GROUP_BY_STORAGE_KEY - ); - return signal(saved !== 'false'); - })(); - readonly groupByPlaylist = computed(() => this._groupByPlaylist()); - private readonly _typeFilters = (() => { - const saved = localStorage.getItem( - RecentlyViewedComponent.TYPE_FILTERS_STORAGE_KEY - ); - if (!saved) { - return signal({ live: true, movie: true, series: true }); - } - try { - const parsed = JSON.parse(saved) as { - live?: boolean; - movie?: boolean; - series?: boolean; - }; - return signal({ - live: parsed.live !== false, - movie: parsed.movie !== false, - series: parsed.series !== false, - }); - } catch { - return signal({ live: true, movie: true, series: true }); - } - })(); - readonly typeFilters = computed(() => this._typeFilters()); - readonly recentItems = computed(() => - (this.isGlobal - ? this.xtreamStore.globalRecentItems() - : this.xtreamStore.recentItems()) as XtreamRecentViewItem[] - ); - readonly categories = computed(() => { - const items = this.recentItems(); - const movies = items.filter((item) => item.type === 'movie').length; - const live = items.filter((item) => item.type === 'live').length; - const series = items.filter((item) => item.type === 'series').length; - - return buildStandardCollectionCategories({ - labels: XTREAM_COLLECTION_LABELS, - counts: { - all: items.length, - movie: movies, - live, - series, - }, - includeLive: true, - }); - }); - readonly collectionContext = createPortalCollectionContext({ - ctx: this.collectionCtx, - categories: this.categories, - enabled: () => !this.isGlobal, - }); - readonly selectedLiveItem = signal(null); - readonly liveStreamUrl = signal(''); - readonly nonGlobalMode = computed(() => - this.isLiveCategory() ? 'live' : 'grid' - ); - readonly selectedCategoryId = this.collectionContext.selectedCategoryId; - readonly recentSearchTerm = signal(''); - readonly workspaceSearchTerm = queryParamSignal( - this.activatedRoute, - 'q', - (value) => (value ?? '').trim().toLowerCase() - ); - readonly filteredRecentItems = computed(() => { - const term = - this.isWorkspaceLayout && !this.isGlobal - ? this.workspaceSearchTerm() - : this.recentSearchTerm().trim().toLowerCase(); - const typeFilters = this.typeFilters(); - const items = this.recentItems().filter((item) => { - if (item.type === 'live') return typeFilters.live; - if (item.type === 'movie') return typeFilters.movie; - if (item.type === 'series') return typeFilters.series; - return true; - }); - if (!term) return items; - - return items.filter((item) => - `${item.title} ${item.playlist_name ?? ''}` - .toLowerCase() - .includes(term) - ); - }); - readonly visibleRecentItems = computed(() => { - const items = this.filteredRecentItems(); - if (this.isGlobal && !this.groupByPlaylist()) { - return items.slice( - 0, - RecentlyViewedComponent.MAX_FLAT_GLOBAL_ITEMS - ); - } - return items; - }); - readonly nonGlobalItemsToShow = computed(() => { - const items = this.visibleRecentItems(); - return filterCollectionBucket({ - selectedCategoryId: this.selectedCategoryId(), - allItems: items, - buckets: { - movie: items.filter((item) => item.type === 'movie'), - live: items.filter((item) => item.type === 'live'), - series: items.filter((item) => item.type === 'series'), - }, - textOf: (item) => `${item.title} ${item.playlist_name ?? ''}`, - }); - }); - readonly liveItemsToShow = computed(() => - this.visibleRecentItems() - .filter((item) => { - if (item.type !== 'live') { - return false; - } - - const term = - this.isWorkspaceLayout && !this.isGlobal - ? this.workspaceSearchTerm() - : this.recentSearchTerm().trim().toLowerCase(); - if (!term) { - return true; - } - - return `${item.title} ${item.playlist_name ?? ''}` - .toLowerCase() - .includes(term); - }) - .map((item) => ({ - id: item.id, - poster_url: item.poster_url, - stream_icon: item.stream_icon, - title: item.title, - xtream_id: Number(item.xtream_id), - })) - ); - readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'live'); - readonly isEmbeddedPlayer = computed(() => - this.portalPlayer.isEmbeddedPlayer() - ); - readonly liveChannelSortMode = signal('server'); - readonly liveChannelSortLabel = computed(() => { - const mode = this.liveChannelSortMode(); - if (mode === 'name-asc') return 'Name A-Z'; - if (mode === 'name-desc') return 'Name Z-A'; - return 'Server Order'; - }); - readonly currentPlaylist = this.xtreamStore.currentPlaylist; - readonly playlistTitle = computed( - () => - this.currentPlaylist()?.name || - this.currentPlaylist()?.title || - 'Playlist' - ); - readonly epgItems = this.xtreamStore.epgItems; - readonly isLoadingEpg = this.xtreamStore.isLoadingEpg; - - constructor( - @Optional() public dialogRef?: MatDialogRef - ) { - this.xtreamStore.setSelectedContentType(undefined); - const savedSortMode = localStorage.getItem( - XTREAM_RECENT_LIVE_SORT_STORAGE_KEY - ); - if ( - savedSortMode === 'server' || - savedSortMode === 'name-asc' || - savedSortMode === 'name-desc' - ) { - this.liveChannelSortMode.set(savedSortMode); - } - - effect(() => { - if (this.isGlobal) { - return; - } - - const selectedItem = this.selectedLiveItem(); - if (!selectedItem) { - return; - } - - const stillExists = this.liveItemsToShow().some( - (item) => - Number(item.xtream_id ?? item.id) === - Number(selectedItem.xtream_id ?? selectedItem.id) - ); - - if (!stillExists) { - this.clearLiveSelection(); - } - }); - - effect(() => { - if (this.isGlobal || this.isLiveCategory()) { - return; - } - - this.clearLiveSelection(); - }); - - if (this.isGlobal) { - this.loadGlobalItems(); - } else if (this.currentPlaylist()) { - this.xtreamStore.loadRecentItems(this.currentPlaylist); - } - } - - private async loadGlobalItems() { - try { - await this.xtreamStore.loadGlobalRecentItems(); - } catch (error) { - this.logger.error('Error loading global items', error); - } - } - - clearHistory() { - if (this.isGlobal) { - this.xtreamStore.clearGlobalRecentlyViewed(); - } else { - this.xtreamStore.clearRecentItems(this.xtreamStore.currentPlaylist); - } - } - - openItem(item: FavoriteLayoutItem) { - if (!isXtreamRecentViewItem(item)) { - return; - } - - const source = item.source ?? 'xtream'; - - if ((source === 'stalker' || source === 'm3u') && this.isGlobal) { - this.dialogRef?.close(); - const navigation = getRecentItemNavigation({ - id: item.id, - title: item.title, - type: item.type, - playlist_id: item.playlist_id, - poster_url: item.poster_url ?? '', - xtream_id: item.xtream_id, - category_id: item.category_id ?? item.type, - source, - stalker_item: item.stalker_item, - viewed_at: item.viewed_at ?? '', - }); - void this.router.navigate(navigation.link, { - state: navigation.state, - }); - return; - } - - const type = item.type === 'movie' ? 'vod' : item.type; - this.xtreamStore.setSelectedContentType(type); - - if (!this.isGlobal && type === 'live') { - this.setCategoryId('live'); - this.selectLiveItem({ - id: item.id, - poster_url: item.poster_url, - stream_icon: item.stream_icon, - title: item.title, - xtream_id: Number(item.xtream_id), - }); - return; - } - - if (this.isGlobal) { - this.dialogRef?.close(); - const navigation = buildXtreamNavigationTarget({ - playlistId: item.playlist_id, - type: item.type, - categoryId: item.category_id, - itemId: item.xtream_id, - title: item.title, - imageUrl: item.poster_url, - }); - void this.router.navigate(navigation.link, { - state: navigation.state, - }); - } else { - const navigation = buildXtreamNavigationTarget({ - playlistId: item.playlist_id, - type: item.type, - categoryId: item.category_id, - itemId: item.xtream_id, - title: item.title, - imageUrl: item.poster_url, - }); - void this.router.navigate(navigation.link, { - state: navigation.state, - }); - } - } - - async onRemoveItem(item: FavoriteLayoutItem) { - if (!isXtreamRecentViewItem(item)) { - return; - } - - try { - if (this.isGlobal) { - if (item.source === 'stalker') { - await firstValueFrom( - this.playlistsService.removeFromPortalRecentlyViewed( - item.playlist_id, - item.id - ) - ); - } else if (item.source === 'm3u') { - await firstValueFrom( - this.playlistsService.removeFromM3uRecentlyViewed( - item.playlist_id, - String(item.xtream_id ?? item.id) - ) - ); - } else { - await this.dbService.removeRecentItem( - Number(item.id), - item.playlist_id - ); - } - await this.loadGlobalItems(); - return; - } - - const playlist = this.currentPlaylist(); - if (!playlist) { - return; - } - - this.xtreamStore.removeRecentItem({ - itemId: Number(item.id), - playlistId: playlist.id, - }); - } catch (error) { - this.logger.error('Error removing recent item', error); - } - } - - getGroupedItems() { - const items = this.visibleRecentItems(); - if (!this.isGlobal) return { default: items }; - return groupItemsByPlaylistName(items); - } - - updateRecentSearchTerm(term: string) { - this.recentSearchTerm.set(term); - } - - toggleGroupByPlaylist(value: boolean) { - this._groupByPlaylist.set(value); - localStorage.setItem( - RecentlyViewedComponent.GROUP_BY_STORAGE_KEY, - String(value) - ); - } - - toggleTypeFilter(type: 'live' | 'movie' | 'series', value: boolean) { - const next = { - ...this._typeFilters(), - [type]: value, - }; - this._typeFilters.set(next); - localStorage.setItem( - RecentlyViewedComponent.TYPE_FILTERS_STORAGE_KEY, - JSON.stringify(next) - ); - } - - setCategoryId(categoryId: string): void { - this.collectionContext.setCategoryId(categoryId); - } - - selectLiveItem(item: XtreamRecentLiveItem): void { - this.xtreamStore.setSelectedContentType('live'); - this.selectedLiveItem.set(item); - const streamUrl = this.xtreamStore.constructStreamUrl(item); - this.liveStreamUrl.set(streamUrl); - - if (this.isEmbeddedPlayer()) { - return; - } - - this.xtreamStore.openPlayer( - streamUrl, - item.title, - item.poster_url || item.stream_icon || null - ); - } - - setLiveChannelSortMode(mode: LiveChannelSortMode): void { - this.liveChannelSortMode.set(mode); - localStorage.setItem(XTREAM_RECENT_LIVE_SORT_STORAGE_KEY, mode); - } - - private clearLiveSelection(): void { - this.selectedLiveItem.set(null); - this.liveStreamUrl.set(''); - this.xtreamStore.clearEpg(); - this.xtreamStore.setSelectedItem(null); - } -} diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts index 206a667a1..6912a0cc7 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts @@ -10,6 +10,11 @@ import { XtreamStore, } from '@iptvnator/portal/xtream/data-access'; +jest.mock('@iptvnator/portal/shared/ui', () => ({ + ContentCardComponent: class {}, + SearchLayoutComponent: class {}, +})); + const DEFAULT_SEARCH_FILTERS: SearchFilters = { live: true, movie: true, @@ -32,6 +37,17 @@ function createSearchItem( }; } +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 }; +} + class MockXtreamStore { readonly searchTerm = signal(''); readonly searchFilters = signal(DEFAULT_SEARCH_FILTERS); @@ -79,6 +95,7 @@ describe('SearchResultsComponent initialQuery contract', () => { useValue: { snapshot: { data: {}, + queryParamMap: convertToParamMap({ q: '' }), }, queryParamMap: of(convertToParamMap({ q: '' })), }, @@ -100,7 +117,8 @@ describe('SearchResultsComponent initialQuery contract', () => { { isGlobalSearch: true, initialQuery: 'matrix', - } + }, + undefined ) ); @@ -115,7 +133,8 @@ describe('SearchResultsComponent initialQuery contract', () => { new SearchResultsComponent( { isGlobalSearch: true, - } + }, + undefined ) ); @@ -129,7 +148,8 @@ describe('SearchResultsComponent initialQuery contract', () => { new SearchResultsComponent( { isGlobalSearch: true, - } + }, + undefined ) ); @@ -146,4 +166,38 @@ describe('SearchResultsComponent initialQuery contract', () => { } ); }); + + it('ignores stale global search responses once a newer search has completed', async () => { + const staleSearch = createDeferred(); + const freshResults = [ + createSearchItem({ xtream_id: 777, title: 'Fresh result' }), + ]; + const databaseService = TestBed.inject(DatabaseService) as { + globalSearchContent: jest.Mock; + }; + databaseService.globalSearchContent + .mockReturnValueOnce(staleSearch.promise) + .mockResolvedValueOnce(freshResults); + + const component = TestBed.runInInjectionContext( + () => + new SearchResultsComponent({ + isGlobalSearch: true, + }, undefined) + ); + const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + + const stalePromise = component.searchGlobal('mat', ['movie']); + const freshPromise = component.searchGlobal('matrix', ['movie']); + + await freshPromise; + expect(store.searchResults()).toEqual(freshResults); + + staleSearch.resolve([ + createSearchItem({ xtream_id: 1, title: 'Stale result' }), + ]); + await stalePromise; + + expect(store.searchResults()).toEqual(freshResults); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts index 8cad8caca..fc69e49c4 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts @@ -92,6 +92,7 @@ export class SearchResultsComponent implements AfterViewInit { /** Search filters from store */ readonly filters = this.xtreamStore.searchFilters; + private globalSearchRequestVersion = 0; private static readonly GROUP_BY_STORAGE_KEY = 'global-search-group-by-playlist'; @@ -249,10 +250,13 @@ export class SearchResultsComponent implements AfterViewInit { * Clear only the results, not the search term/filters */ private clearResultsOnly() { + this.globalSearchRequestVersion++; + this.xtreamStore.setIsSearching(false); this.xtreamStore.setGlobalSearchResults([]); } async searchGlobal(term: string, types: string[], excludeHidden?: boolean) { + const requestVersion = ++this.globalSearchRequestVersion; this.xtreamStore.setIsSearching(true); try { const results = await this.databaseService.globalSearchContent( @@ -260,12 +264,21 @@ export class SearchResultsComponent implements AfterViewInit { types, excludeHidden ); + + if (requestVersion !== this.globalSearchRequestVersion) { + return; + } + if (results && Array.isArray(results)) { this.xtreamStore.setGlobalSearchResults(results); } else { this.xtreamStore.setIsSearching(false); } } catch (error) { + if (requestVersion !== this.globalSearchRequestVersion) { + return; + } + this.logger.error('Error in global search', error); this.xtreamStore.resetSearchResults(); } diff --git a/libs/portal/xtream/feature/src/test-setup.ts b/libs/portal/xtream/feature/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/portal/xtream/feature/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/portal/xtream/feature/tsconfig.spec.json b/libs/portal/xtream/feature/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/portal/xtream/feature/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +}