Merge pull request #903 from sapireli/search_all_fix

Fix Xtream section search across categories
This commit is contained in:
4gray authored and GitHub committed 2026-05-08 22:09:36 +02:00
commit e9875f9f29
8 files changed
+383 -63

No files matched your search

+181 -3
View File
@@ -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<XtreamVodFixture> {
return fetchXtreamFixture(request, {
categoriesAction: 'get_live_categories',
itemsAction: 'get_live_streams',
});
}
async function fetchXtreamSeriesFixture(
request: APIRequestContext
): Promise<XtreamVodFixture> {
@@ -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<void> {
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<void> {
const addButton = page
.locator('button.favorite-btn')
@@ -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 {
@@ -1,7 +1,7 @@
@let isEmbeddedPlayer = usesEmbeddedPlayer();
@let streamUrl = activeStreamUrl();
@if (selectedCategoryId()) {
@if (showLiveChannelSidebar()) {
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
@@ -14,7 +14,7 @@
<div class="sidebar-header">
<div class="category-heading">
<h2 class="category-title">
{{ selectedCategoryInfo()?.name }}
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
</h2>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
@@ -92,23 +92,7 @@
<mat-icon>chevron_right</mat-icon>
</button>
}
@if (!selectedCategoryId()) {
@if (isSelectedTypeContentLoading()) {
<div class="live-loading-content">
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
[searchTermInput]="workspaceSearchTerm()"
(playClicked)="playLive($event)"
(playbackRequested)="playLive($event, true)"
/>
</div>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
}
} @else if (streamUrl) {
@if (streamUrl) {
@if (isEmbeddedPlayer) {
<div class="video-player">
<app-web-player-view
@@ -150,6 +134,22 @@
/>
}
</div>
} @else if (!showLiveChannelSidebar()) {
@if (isSelectedTypeContentLoading()) {
<div class="live-loading-content">
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
[searchTermInput]="workspaceSearchTerm()"
(playClicked)="playLive($event)"
(playbackRequested)="playLive($event, true)"
/>
</div>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'PORTALS.SELECT_CATEGORY' | translate"
/>
}
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -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,
@@ -105,6 +106,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 = {
@@ -194,6 +198,7 @@ describe('LiveStreamLayoutComponent', () => {
selectedContentType.set('live');
selectedItem.set(sampleChannel);
currentPlaylist.set(playlist);
routeQueryParamMap = new BehaviorSubject(convertToParamMap({}));
await TestBed.configureTestingModule({
imports: [LiveStreamLayoutComponent, NoopAnimationsModule],
@@ -205,11 +210,11 @@ describe('LiveStreamLayoutComponent', () => {
data: {},
queryParamMap: convertToParamMap({}),
},
queryParamMap: of(convertToParamMap({})),
queryParamMap: routeQueryParamMap.asObservable(),
pathFromRoot: [
{
snapshot: {
data: {},
data: { layout: 'workspace' },
},
},
],
@@ -350,6 +355,56 @@ 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 lists = fixture.debugElement.queryAll(
By.directive(StubPortalChannelsListComponent)
);
const list = lists[0];
expect(list).not.toBeNull();
expect(lists).toHaveLength(1);
expect(list.componentInstance.searchTermInput() as string).toBe(
'world'
);
expect(
fixture.nativeElement.querySelector(
'.sidebar [data-test-id="portal-channels-list-stub"]'
)
).not.toBeNull();
});
it('shows embedded playback after selecting a channel from live root search results', () => {
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
routeQueryParamMap.next(convertToParamMap({ q: 'world' }));
fixture.detectChanges();
const list = fixture.debugElement.query(
By.directive(StubPortalChannelsListComponent)
);
list.componentInstance.playClicked.emit(sampleChannel);
fixture.detectChanges();
expect(xtreamStore.constructStreamUrl).toHaveBeenCalledWith(
sampleChannel
);
expect(
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
).not.toBeNull();
expect(
fixture.nativeElement.querySelector(
'.sidebar [data-test-id="portal-channels-list-stub"]'
)
).not.toBeNull();
});
it('renders the current EPG program in the collapsible panel summary', () => {
epgItems.set([
buildEpgItem(
@@ -53,11 +53,7 @@ import {
LiveEpgPanelSummary,
WebPlayerViewComponent,
} from 'shared-portals';
import {
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
} from 'shared-interfaces';
import { EpgItem, EpgProgram, ResolvedPortalPlayback } from 'shared-interfaces';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { ActivatedRoute } from '@angular/router';
import { SettingsStore } from 'services';
@@ -126,6 +122,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly workspaceSearchTerm = computed(() =>
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
);
readonly showLiveChannelSidebar = computed(
() => !!this.selectedCategoryId() || !!this.workspaceSearchTerm()
);
private readonly pendingAutoOpenLiveItemId = signal<number | null>(null);
readonly selectedLiveItem = computed<XtreamLiveChannelItem | null>(() => {
if (this.xtreamStore.selectedContentType() !== 'live') {
@@ -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);
}
}