Fix Xtream section search across categories

This commit is contained in:
Eliran Sapir committed 2026-05-07 17:42:29 -04:00
1 parent f9843fcb0c
commit a3a4c6b5ea
6 files changed
+150 -37

No files matched your search

@@ -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']);
});
});
@@ -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 {
@@ -92,7 +92,7 @@
</button>
}
@if (!selectedCategoryId()) {
@if (isSelectedTypeContentLoading()) {
@if (isSelectedTypeContentLoading() || workspaceSearchTerm()) {
<div class="live-loading-content">
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
@@ -1,10 +1,11 @@
import { Directive, Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { MockPipe } from 'ng-mocks';
import { TranslatePipe } from '@ngx-translate/core';
import { of } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import {
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
@@ -103,6 +104,9 @@ class StubResizableDirective {}
describe('LiveStreamLayoutComponent', () => {
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
let component: LiveStreamLayoutComponent;
let routeQueryParamMap: BehaviorSubject<
ReturnType<typeof convertToParamMap>
>;
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(
@@ -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<string, string | number>
) => {
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();
@@ -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);
}
}