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 1/2] 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); } - } From 6efa8b90729430d25a470d4b81a0856113fdc4bf Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 8 May 2026 21:05:10 +0200 Subject: [PATCH 2/2] fix(xtream): preserve live search playback sidebar Entire-Checkpoint: f957cd9849e0 --- apps/electron-backend-e2e/src/search.e2e.ts | 184 +++++++++++++++++- .../live-stream-layout.component.html | 36 ++-- .../live-stream-layout.component.spec.ts | 36 +++- .../live-stream-layout.component.ts | 9 +- 4 files changed, 236 insertions(+), 29 deletions(-) diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 291dd7175..653acae96 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -206,7 +206,10 @@ test.describe('Electron Workspace Search', () => { await fillWorkspaceSearch(app.mainWindow, sample.groupTitle); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await expectQueryParam(app.mainWindow, 'q', sample.groupTitle); await expectWorkspaceSearchScope( app.mainWindow, @@ -411,6 +414,134 @@ test.describe('Electron Workspace Search', () => { } }); + test('@search @xtream filters VOD and series root views across categories', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const vodSample = await fetchXtreamVodFixture(request); + const seriesSample = await fetchXtreamSeriesFixture(request); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + password: xtreamSearchPassword, + username: xtreamSearchUsername, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await expectXtreamRootCatalogSearch(app.mainWindow, { + pathPattern: /\/workspace\/xtreams\/[^/]+\/vod$/, + sample: vodSample, + sectionLabel: 'Movies', + }); + await expect( + app.mainWindow.locator(headerSearchSelector) + ).toHaveValue(vodSample.targetTitle); + + await fillWorkspaceSearch(app.mainWindow, ''); + await openWorkspaceSection(app.mainWindow, 'Series'); + + await expectXtreamRootCatalogSearch(app.mainWindow, { + pathPattern: /\/workspace\/xtreams\/[^/]+\/series$/, + sample: seriesSample, + sectionLabel: 'Series', + }); + await expect( + app.mainWindow.locator(headerSearchSelector) + ).toHaveValue(seriesSample.targetTitle); + } finally { + await closeElectronApp(app); + } + }); + + test('@search @xtream filters live root and category scopes while preserving playback sidebar', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const sample = await fetchXtreamLiveFixture(request); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + password: xtreamSearchPassword, + username: xtreamSearchUsername, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/live$/ + ); + + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/live$/ + ); + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await expectWorkspaceSearchScope( + app.mainWindow, + 'Live TV / All Items' + ); + await expect( + liveChannelSidebar(app.mainWindow) + .locator('[data-test-id="channel-item"]') + .first() + ).toBeVisible({ timeout: 20000 }); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible({ timeout: 20000 }); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + + await channelItemByTitle(app.mainWindow, sample.targetTitle) + .first() + .click(); + + await expect( + app.mainWindow.locator( + 'app-live-stream-layout .content-container .video-player' + ) + ).toBeVisible({ timeout: 20000 }); + await expect( + liveChannelSidebar(app.mainWindow) + .locator('[data-test-id="channel-item"]') + .first() + ).toBeVisible(); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toHaveClass(/(^|\s)active(\s|$)/); + + await fillWorkspaceSearch(app.mainWindow, ''); + await clickCategoryByNameExact(app.mainWindow, sample.categoryName); + await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); + + await expectPathname( + app.mainWindow, + /\/workspace\/xtreams\/[^/]+\/live(?:\/[^/]+)?$/ + ); + await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); + await expectWorkspaceSearchScope( + app.mainWindow, + `Live TV / ${sample.categoryName}` + ); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toBeVisible({ timeout: 20000 }); + await expect( + channelItemByTitle(app.mainWindow, sample.controlTitle) + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + test('@search @xtream promotes dashboard header search into advanced playlist search', async ({ dataDir, request, @@ -561,7 +692,10 @@ test.describe('Electron Workspace Search', () => { ); await openWorkspaceSection(app.mainWindow, 'Favorites'); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await expect( contentCardByTitle( app.mainWindow, @@ -801,7 +935,10 @@ test.describe('Electron Workspace Search', () => { await toggleFavoriteForChannel(app.mainWindow, sample.controlTitle); await openWorkspaceSection(app.mainWindow, 'Favorites'); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await expect( channelItemByTitle(app.mainWindow, sample.targetTitle).first() ).toBeVisible({ timeout: 20000 }); @@ -911,6 +1048,15 @@ async function fetchXtreamVodFixture( }); } +async function fetchXtreamLiveFixture( + request: APIRequestContext +): Promise { + return fetchXtreamFixture(request, { + categoriesAction: 'get_live_categories', + itemsAction: 'get_live_streams', + }); +} + async function fetchXtreamSeriesFixture( request: APIRequestContext ): Promise { @@ -1262,6 +1408,10 @@ function xtreamSearchResultCards(page: Page) { return page.locator('app-search-results .results-grid .content-card'); } +function liveChannelSidebar(page: Page) { + return page.locator('app-live-stream-layout .sidebar'); +} + function gridListCardByTitle(page: Page, title: string) { return page.locator('.category-content-layout mat-card').filter({ has: page.locator('.title', { @@ -1329,6 +1479,34 @@ async function resolveVisibleXtreamTitles( }; } +async function expectXtreamRootCatalogSearch( + page: Page, + options: { + pathPattern: RegExp; + sample: XtreamVodFixture; + sectionLabel: string; + } +): Promise { + await openWorkspaceSection(page, options.sectionLabel); + await expectPathname(page, options.pathPattern); + await fillWorkspaceSearch(page, options.sample.targetTitle); + + await expectPathname(page, options.pathPattern); + await expectQueryParam(page, 'q', options.sample.targetTitle); + await expectWorkspaceSearchScope( + page, + `${options.sectionLabel} / All Items` + ); + const contentLayout = page.locator('.category-content-layout'); + await expect(contentLayout).toContainText( + flexibleTextPattern(options.sample.targetTitle), + { timeout: 20000 } + ); + await expect(contentLayout).not.toContainText( + flexibleTextPattern(options.sample.controlTitle) + ); +} + async function addCurrentDetailToFavorites(page: Page): Promise { const addButton = page .locator('button.favorite-btn') 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 9a2fe6cce..edf388a22 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 @@ -1,7 +1,7 @@ @let isEmbeddedPlayer = usesEmbeddedPlayer(); @let streamUrl = activeStreamUrl(); -@if (selectedCategoryId()) { +@if (showLiveChannelSidebar()) {