From a3a4c6b5ea4a38c3f885ebb84e755ec20a396662 Mon Sep 17 00:00:00 2001 From: Eliran Sapir <114778791+sapireli@users.noreply.github.com> Date: Thu, 7 May 2026 05:13:00 -0400 Subject: [PATCH] Fix Xtream section search across categories --- .../features/with-selection.feature.spec.ts | 68 ++++++++++++++++++- .../stores/features/with-selection.feature.ts | 6 +- .../live-stream-layout.component.html | 2 +- .../live-stream-layout.component.spec.ts | 31 ++++++++- .../services/workspace-shell.facade.spec.ts | 65 ++++++++++++------ .../services/workspace-shell.facade.ts | 15 ++-- 6 files changed, 150 insertions(+), 37 deletions(-) diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts index d7676347c..ff5268eba 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.spec.ts @@ -9,8 +9,37 @@ const TestSelectionStore = signalStore( vod: 'ready', series: 'ready', }, - liveCategories: [], - liveStreams: [], + liveCategories: [ + { + id: 50, + category_id: '50', + category_name: 'News', + type: 'live', + }, + { + id: 60, + category_id: '60', + category_name: 'Sports', + type: 'live', + }, + ], + liveStreams: [ + { + xtream_id: 201, + category_id: '50', + title: 'World News', + }, + { + xtream_id: 202, + category_id: '60', + title: 'World Sports', + }, + { + xtream_id: 203, + category_id: '60', + title: 'Match Day', + }, + ], vodCategories: [ { id: 10, @@ -175,7 +204,7 @@ describe('withSelection', () => { ).toEqual(['Stargate SG-1', 'Stargate Atlantis']); }); - it('keeps VOD search scoped to the selected category', () => { + it('keeps VOD category route search scoped to the selected category', () => { store.setSelectedContentType('vod'); store.setSelectedCategory(10); @@ -185,4 +214,37 @@ describe('withSelection', () => { store.selectItemsFromSelectedCategory().map((item) => item.title) ).toEqual(['First']); }); + + it('keeps series category route search scoped to the selected category', () => { + store.setSelectedContentType('series'); + store.setSelectedCategory(30); + + store.setCategorySearchTerm('stargate'); + + expect( + store.selectItemsFromSelectedCategory().map((item) => item.title) + ).toEqual(['Stargate SG-1']); + }); + + it('keeps live category route search scoped to the selected category', () => { + store.setSelectedContentType('live'); + store.setSelectedCategory(50); + + store.setCategorySearchTerm('world'); + + expect( + store.selectItemsFromSelectedCategory().map((item) => item.title) + ).toEqual(['World News']); + }); + + it('filters all live items across categories when no category is selected', () => { + store.setSelectedContentType('live'); + store.setSelectedCategory(null); + + store.setCategorySearchTerm('world'); + + expect( + store.selectItemsFromSelectedCategory().map((item) => item.title) + ).toEqual(['World News', 'World Sports']); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts index fe2fcfb33..7157306ee 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-selection.feature.ts @@ -276,19 +276,19 @@ export function withSelection() { : sortedContent(); filtered = filterBySearchTerm(filtered, searchTerm); - return categoryId + return categoryId || searchTerm ? sortByMode(filtered, sortMode, categoryType) : filtered; } if (!categoryId) { - return sortedContent(); + return filterBySearchTerm(sortedContent(), searchTerm); } const filtered = content.filter( (item) => Number(item.category_id) === categoryId ); - return filtered; + return filterBySearchTerm(filtered, searchTerm); }); return { 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 fcdfe651f..9a2fe6cce 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 @@ -92,7 +92,7 @@ } @if (!selectedCategoryId()) { - @if (isSelectedTypeContentLoading()) { + @if (isSelectedTypeContentLoading() || workspaceSearchTerm()) {
{ let fixture: ComponentFixture; let component: LiveStreamLayoutComponent; + let routeQueryParamMap: BehaviorSubject< + ReturnType + >; const fixedNow = new Date('2026-04-05T12:00:00.000Z'); const sampleChannel = { @@ -188,6 +192,7 @@ describe('LiveStreamLayoutComponent', () => { selectedContentType.set('live'); selectedItem.set(sampleChannel); currentPlaylist.set(playlist); + routeQueryParamMap = new BehaviorSubject(convertToParamMap({})); await TestBed.configureTestingModule({ imports: [LiveStreamLayoutComponent, NoopAnimationsModule], @@ -199,11 +204,11 @@ describe('LiveStreamLayoutComponent', () => { data: {}, queryParamMap: convertToParamMap({}), }, - queryParamMap: of(convertToParamMap({})), + queryParamMap: routeQueryParamMap.asObservable(), pathFromRoot: [ { snapshot: { - data: {}, + data: { layout: 'workspace' }, }, }, ], @@ -343,6 +348,26 @@ describe('LiveStreamLayoutComponent', () => { ).toBeNull(); }); + it('shows the cross-category live channel list while searching from the live root', () => { + selectedCategoryId.set(null); + selectedTypeContentLoading.set(false); + routeQueryParamMap.next(convertToParamMap({ q: 'world' })); + + fixture.detectChanges(); + + const list = fixture.debugElement.query( + By.directive(StubPortalChannelsListComponent) + ); + + expect(list).not.toBeNull(); + expect(list.componentInstance.searchTermInput() as string).toBe( + 'world' + ); + 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/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 0f569b249..864202a55 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -17,10 +17,7 @@ import { } from '@iptvnator/portal/shared/util'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { - PlaylistsService, - SettingsStore, -} from 'services'; +import { PlaylistsService, SettingsStore } from 'services'; import { PlaylistMeta } from 'shared-interfaces'; import { WorkspaceStartupPreferencesService, @@ -285,7 +282,8 @@ describe('WorkspaceShellFacade', () => { params?: Record ) => { if ( - key === 'WORKSPACE.SHELL.XTREAM_IMPORT_PROGRESS' && + key === + 'WORKSPACE.SHELL.XTREAM_IMPORT_PROGRESS' && params ) { return `${params.type} imported: ${params.current} / ${params.total}`; @@ -325,7 +323,9 @@ describe('WorkspaceShellFacade', () => { }); it('routes dashboard search Enter into the active Xtream playlist search', () => { - const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + const xtreamStore = TestBed.inject( + XtreamStore + ) as unknown as MockXtreamStore; facade.currentUrl.set('/workspace/dashboard'); router.navigate.mockClear(); @@ -342,7 +342,9 @@ describe('WorkspaceShellFacade', () => { }); it('uses a loading label for remote Xtream fetch phases', () => { - const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + const xtreamStore = TestBed.inject( + XtreamStore + ) as unknown as MockXtreamStore; const xtreamImport = TestBed.inject(WorkspaceShellXtreamImportService); xtreamStore.currentImportPhase.set('loading-categories'); @@ -446,7 +448,9 @@ describe('WorkspaceShellFacade', () => { }); it('builds a type-aware xtream import progress label', () => { - const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + const xtreamStore = TestBed.inject( + XtreamStore + ) as unknown as MockXtreamStore; const xtreamImport = TestBed.inject(WorkspaceShellXtreamImportService); xtreamStore.activeImportContentType.set('vod'); @@ -486,9 +490,12 @@ describe('WorkspaceShellFacade', () => { facade.onSearchEnter(''); TestBed.flushEffects(); - expect(router.navigateByUrl).toHaveBeenCalledWith('/workspace/sources', { - replaceUrl: true, - }); + expect(router.navigateByUrl).toHaveBeenCalledWith( + '/workspace/sources', + { + replaceUrl: true, + } + ); }); it('navigates to the global favorites route', () => { @@ -513,7 +520,9 @@ describe('WorkspaceShellFacade', () => { ); expect(storeDispatch).toHaveBeenCalled(); expect(router.navigateByUrl).toHaveBeenCalledWith( - expect.stringMatching(/^\/workspace\/stalker\/pl-1\/recent\?refresh=/), + expect.stringMatching( + /^\/workspace\/stalker\/pl-1\/recent\?refresh=/ + ), { replaceUrl: true, } @@ -533,7 +542,9 @@ describe('WorkspaceShellFacade', () => { }); it('applies q to Xtream category search on vod routes', () => { - const xtreamStore = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + const xtreamStore = TestBed.inject( + XtreamStore + ) as unknown as MockXtreamStore; facade.currentUrl.set('/workspace/xtreams/pl-1/vod?q=neo'); (facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute(); @@ -543,6 +554,19 @@ describe('WorkspaceShellFacade', () => { expect(xtreamStore.setSearchTerm).not.toHaveBeenCalled(); }); + it('applies q to Xtream category search on live routes', () => { + const xtreamStore = TestBed.inject( + XtreamStore + ) as unknown as MockXtreamStore; + + facade.currentUrl.set('/workspace/xtreams/pl-1/live?q=world'); + (facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute(); + TestBed.flushEffects(); + + expect(xtreamStore.setCategorySearchTerm).toHaveBeenCalledWith('world'); + expect(xtreamStore.setSearchTerm).not.toHaveBeenCalled(); + }); + it('enables local-filter search on playlist favorites routes', () => { facade.currentUrl.set('/workspace/playlists/pl-1/favorites?q=news'); (facade as { syncSearchFromRoute: () => void }).syncSearchFromRoute(); @@ -586,9 +610,9 @@ describe('WorkspaceShellFacade', () => { }); it('persists the last restorable route from navigation events', () => { - expect(startupPreferences.persistLastRestorablePath).toHaveBeenCalledWith( - '/workspace/xtreams/pl-1/vod' - ); + expect( + startupPreferences.persistLastRestorablePath + ).toHaveBeenCalledWith('/workspace/xtreams/pl-1/vod'); }); it('uses the translated recent scope label on the global recent route', () => { @@ -689,9 +713,9 @@ describe('WorkspaceShellFacade', () => { expect(commands[0]?.id).toBe('clear-current-favorites'); expect(commands[0]?.group).toBe('view'); - expect(commands.some((command) => command.id === 'playlist-search')).toBe( - false - ); + expect( + commands.some((command) => command.id === 'playlist-search') + ).toBe(false); unregister(); }); @@ -701,8 +725,7 @@ describe('WorkspaceShellFacade', () => { open: jest.Mock; }; dialog.open.mockReturnValueOnce({ - afterClosed: () => - of({ commandId: 'open-settings', query: '' }), + afterClosed: () => of({ commandId: 'open-settings', query: '' }), }); facade.openCommandPalette(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts index 90fbbd6bb..9aa49cb8e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts @@ -576,7 +576,11 @@ export class WorkspaceShellFacade { return; } - if (section === 'vod' || section === 'series') { + if ( + section === 'vod' || + section === 'series' || + section === 'live' + ) { this.xtreamStore.setCategorySearchTerm(term); } }); @@ -617,8 +621,8 @@ export class WorkspaceShellFacade { } openActiveExternalSessionTarget(): void { - const playlistId = this.externalPlaybackSession()?.contentInfo - ?.playlistId; + const playlistId = + this.externalPlaybackSession()?.contentInfo?.playlistId; if (!playlistId) return; const playlist = this.playlists().find((p) => p._id === playlistId); @@ -792,7 +796,8 @@ export class WorkspaceShellFacade { } private readonly commandBuilderActions: CommandBuilderActions = { - openPlaylistSearch: (query) => this.openPlaylistSearchFromPalette(query), + openPlaylistSearch: (query) => + this.openPlaylistSearchFromPalette(query), refreshCurrentPlaylist: () => this.refreshCurrentPlaylist(), openPlaylistInfo: () => this.openPlaylistInfo(), openAccountInfo: () => this.openAccountInfo(), @@ -806,7 +811,6 @@ export class WorkspaceShellFacade { : this.workspaceActions.openAddPlaylistDialog(), }; - private openPlaylistSearchFromPalette(query: string): void { const effectiveContext = this.dashboardXtreamContext() ?? this.currentContext(); @@ -905,5 +909,4 @@ export class WorkspaceShellFacade { ): string { return this.translate.instant(key, params); } - }