feat(search): add workspace global search with M3U support

Moves global search into a routed workspace view, adds M3U live/radio results, lazy pagination, and DB-backed matching improvements.
This commit is contained in:
4gray authored and GitHub committed 2026-06-20 23:05:48 +02:00
1 parent ffc07146fe
commit 89916af9d8
65 files changed
+3010 -391

No files matched your search

@@ -29,6 +29,7 @@
</span>
}
<input
#searchInput
type="search"
spellcheck="false"
[value]="searchQuery()"
@@ -93,6 +93,19 @@ describe('WorkspaceShellHeaderComponent', () => {
expect(emitted).toEqual(['matrix']);
});
it('focuses and selects the search input on request', () => {
const input: HTMLInputElement =
fixture.nativeElement.querySelector('input[type="search"]');
input.value = 'matrix';
input.blur();
component.focusSearchInput({ select: true });
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe('matrix'.length);
});
it('emits add playlist requests when the toolbar add button is clicked', () => {
const requested = jest.fn();
component.addPlaylistRequested.subscribe(requested);
@@ -1,8 +1,10 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
input,
output,
viewChild,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
@@ -26,6 +28,9 @@ import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shel
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceShellHeaderComponent {
private readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isMac =
typeof navigator !== 'undefined' &&
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
@@ -70,6 +75,23 @@ export class WorkspaceShellHeaderComponent {
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
focusSearchInput(options: { select?: boolean } = {}): void {
const inputElement = this.searchInput()?.nativeElement;
if (!inputElement || inputElement.disabled) {
return;
}
inputElement.focus();
if (options.select) {
inputElement.select();
}
}
containsSearchInput(target: EventTarget | null): boolean {
const inputElement = this.searchInput()?.nativeElement;
return !!inputElement && target === inputElement;
}
onSearchInput(event: Event): void {
const target = event.target as HTMLInputElement | null;
this.searchChanged.emit(target?.value ?? '');
@@ -27,7 +27,7 @@ export interface CommandBuilderContext {
context: WorkspacePortalContext | null;
section: PortalRailSection | null;
hasActivePlaylist: boolean;
hasXtreamPlaylists: boolean;
hasSearchablePlaylists: boolean;
canRefreshPlaylist: boolean;
supportsDownloads: boolean;
showDashboard: boolean;
@@ -141,7 +141,7 @@ export function getGlobalCommandDefinitions(
): WorkspaceCommandContribution[] {
const {
route,
hasXtreamPlaylists,
hasSearchablePlaylists,
supportsDownloads,
showDashboard,
actions,
@@ -156,8 +156,8 @@ export function getGlobalCommandDefinitions(
descriptionKey:
'WORKSPACE.SHELL.COMMANDS.GLOBAL_SEARCH_DESCRIPTION',
priority: 10,
visible: hasXtreamPlaylists,
keywords: ['xtream'],
visible: hasSearchablePlaylists && route.kind !== 'global-search',
keywords: ['xtream', 'm3u', 'live'],
run: ({ query }) => actions.openGlobalSearch(query),
},
{
@@ -130,6 +130,10 @@ export function resolveSearchScopeLabel(ctx: SearchScopeContext): string {
return translate('PORTALS.RECENTLY_VIEWED');
}
if (kind === 'global-search') {
return translate('WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH');
}
if (kind === 'downloads') {
return translate('WORKSPACE.SHELL.RAIL_DOWNLOADS');
}
@@ -0,0 +1,22 @@
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
type SearchablePlaylistMeta = Pick<
PlaylistMeta,
'count' | 'filePath' | 'macAddress' | 'serverUrl' | 'url'
>;
export function isWorkspaceGlobalSearchablePlaylist(
playlist: SearchablePlaylistMeta
): boolean {
if (playlist.serverUrl) {
return true;
}
if (playlist.macAddress) {
return false;
}
return Boolean(
playlist.filePath || playlist.url || Number(playlist.count ?? 0) > 0
);
}
@@ -1,4 +1,10 @@
import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core';
import {
computed,
DestroyRef,
inject,
Injectable,
signal,
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -84,6 +90,17 @@ export class WorkspaceShellRouteStateService {
path: ['/workspace/sources'],
});
if (this.runtime.isElectron) {
links.push({
icon: 'search',
tooltip: this.translateText(
'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH'
),
path: ['/workspace/search'],
exact: true,
});
}
links.push({
icon: 'favorite',
tooltip: this.translateText('HOME.PLAYLISTS.GLOBAL_FAVORITES'),
@@ -125,6 +142,7 @@ export class WorkspaceShellRouteStateService {
currentRoute.kind !== 'settings' &&
currentRoute.kind !== 'global-favorites' &&
currentRoute.kind !== 'global-recent' &&
currentRoute.kind !== 'global-search' &&
currentRoute.kind !== 'downloads'
) {
return null;
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { WorkspaceSearchCapability } from '@iptvnator/workspace/shell/util';
import {
SEARCH_LOADED_ONLY_STATUS,
@@ -14,6 +15,7 @@ import {
resolveSearchPlaceholderKey,
resolveSearchScopeLabel,
} from './helpers/workspace-shell-search-labels';
import { isWorkspaceGlobalSearchablePlaylist } from './helpers/workspace-shell-searchable-playlists';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellSearchSyncService } from './workspace-shell-search-sync.service';
@@ -23,6 +25,7 @@ export class WorkspaceShellSearchService {
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly routeState = inject(WorkspaceShellRouteStateService);
private readonly searchSync = inject(WorkspaceShellSearchSyncService);
@@ -82,6 +85,29 @@ export class WorkspaceShellSearchService {
};
}
if (route.kind === 'global-search') {
const hasSearchablePlaylists =
this.runtime.isElectron &&
this.routeState
.playlists()
.some(isWorkspaceGlobalSearchablePlaylist);
return {
enabled: hasSearchablePlaylists,
behavior: hasSearchablePlaylists ? 'remote-search' : 'disabled',
context: null,
section: null,
searchMode: hasSearchablePlaylists ? 'remote-search' : 'none',
placeholderKey: 'WORKSPACE.SHELL.SEARCH_GLOBAL_PLACEHOLDER',
scopeLabel: this.translateText(
'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH'
),
statusLabel: '',
minLength: hasSearchablePlaylists ? 2 : 0,
advancedRouteTarget: null,
};
}
if (route.searchMode === 'none') {
return {
enabled: false,
@@ -653,6 +653,12 @@ describe('WorkspaceShellFacade', () => {
tooltip: 'WORKSPACE.SHELL.RAIL_SOURCES',
path: ['/workspace/sources'],
},
{
icon: 'search',
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_SEARCH',
path: ['/workspace/search'],
exact: true,
},
{
icon: 'favorite',
tooltip: 'HOME.PLAYLISTS.GLOBAL_FAVORITES',
@@ -669,6 +675,17 @@ describe('WorkspaceShellFacade', () => {
expect(facade.brandLink()).toBe('/workspace/sources');
});
it('hides the Electron-only global search rail link in the web runtime', () => {
runtime.isElectron = false;
showDashboardSignal.set(false);
expect(facade.workspaceLinks().map((link) => link.path)).toEqual([
['/workspace/sources'],
['/workspace/global-favorites'],
['/workspace/global-recent'],
]);
});
it('persists the last restorable route from navigation events', () => {
expect(
startupPreferences.persistLastRestorablePath
@@ -706,6 +723,63 @@ describe('WorkspaceShellFacade', () => {
expect(commands.every((command) => command.enabled)).toBe(true);
});
it('shows the global search command for M3U-only sources', () => {
activePlaylistSignal.set({
_id: 'pl-m3u',
title: 'Playlist M3U',
count: 10,
importDate: '2026-04-22T10:00:00.000Z',
autoRefresh: false,
});
playlistsSignal.set([
{
_id: 'pl-m3u',
title: 'Playlist M3U',
count: 10,
importDate: '2026-04-22T10:00:00.000Z',
autoRefresh: false,
},
]);
facade.currentUrl.set('/workspace/dashboard');
expect(
facade.commandPaletteCommands().map((command) => command.id)
).toContain('global-search');
});
it('does not enable global search for empty invalid playlist metadata', () => {
activePlaylistSignal.set(null);
playlistsSignal.set([
{
_id: 'empty-playlist',
title: 'Empty playlist',
} as PlaylistSignalMeta,
]);
facade.currentUrl.set('/workspace/search');
expect(facade.canUseSearch()).toBe(false);
expect(
facade.commandPaletteCommands().map((command) => command.id)
).not.toContain('global-search');
});
it('hides the global search command and header capability in the web runtime', () => {
runtime.isElectron = false;
playlistsSignal.set([
{
_id: 'pl-m3u',
title: 'Playlist M3U',
count: 10,
} as PlaylistSignalMeta,
]);
facade.currentUrl.set('/workspace/search');
expect(facade.canUseSearch()).toBe(false);
expect(
facade.commandPaletteCommands().map((command) => command.id)
).not.toContain('global-search');
});
it('hides the downloads command when downloads are unsupported', () => {
runtime.supportsDownloads = false;
activePlaylistSignal.set(null);
@@ -17,6 +17,7 @@ import {
CommandBuilderActions,
CommandBuilderContext,
} from './helpers/workspace-shell-command-builders';
import { isWorkspaceGlobalSearchablePlaylist } from './helpers/workspace-shell-searchable-playlists';
import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service';
import { WorkspaceShellHeaderService } from './workspace-shell-header.service';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
@@ -219,9 +220,9 @@ export class WorkspaceShellFacade {
context: this.currentContext(),
section: this.currentSection(),
hasActivePlaylist: !!this.activePlaylist(),
hasXtreamPlaylists: this.playlists().some(
(playlist) => !!playlist.serverUrl
),
hasSearchablePlaylists:
this.isElectron &&
this.playlists().some(isWorkspaceGlobalSearchablePlaylist),
canRefreshPlaylist: this.canRefreshPlaylist(),
supportsDownloads: this.supportsDownloads,
showDashboard: this.showDashboard(),
@@ -22,6 +22,7 @@
/>
<app-workspace-shell-header
#workspaceHeader
[playlistTitle]="facade.playlistTitle()"
[playlistSubtitle]="facade.playlistSubtitle()"
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
@@ -72,6 +72,9 @@ class MockWorkspaceShellHeaderComponent {
readonly headerBulkActionRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
focusSearchInput = jest.fn();
containsSearchInput = jest.fn(() => false);
}
@Component({
@@ -187,6 +190,7 @@ class MockWorkspaceShellFacade {
onSearchInput = jest.fn();
onSearchEnter = jest.fn();
openCommandPalette = jest.fn();
openGlobalSearch = jest.fn();
openAddPlaylistDialog = jest.fn();
runHeaderShortcut = jest.fn();
refreshCurrentPlaylist = jest.fn();
@@ -350,4 +354,59 @@ describe('WorkspaceShellComponent', () => {
expect(shortcutsService.openShortcutsDialog).toHaveBeenCalledTimes(1);
});
it('opens the routed global search and focuses header search on Ctrl/Cmd+F', async () => {
jest.useFakeTimers();
const facade = new MockWorkspaceShellFacade();
await TestBed.configureTestingModule({
imports: [WorkspaceShellComponent],
providers: [provideRouter([])],
})
.overrideComponent(WorkspaceShellComponent, {
set: {
imports: [
RouterOutlet,
MockExternalPlaybackDockComponent,
MockPlaylistDropOverlayComponent,
MockPlaylistDropZoneDirective,
MockWorkspaceShellContextSidebarComponent,
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
],
providers: [
{
provide: WorkspaceShellFacade,
useValue: facade,
},
{
provide: WorkspaceKeyboardShortcutsService,
useClass: MockWorkspaceKeyboardShortcutsService,
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(WorkspaceShellComponent);
fixture.detectChanges();
const header = fixture.debugElement.query(
By.directive(MockWorkspaceShellHeaderComponent)
).componentInstance as MockWorkspaceShellHeaderComponent;
const event = new KeyboardEvent('keydown', {
key: 'f',
metaKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
jest.runOnlyPendingTimers();
expect(event.defaultPrevented).toBe(true);
expect(facade.openGlobalSearch).toHaveBeenCalledWith('');
expect(header.focusSearchInput).toHaveBeenCalledWith({ select: true });
jest.useRealTimers();
});
});
@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { Component, HostListener, inject, viewChild } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ExternalPlaybackDockComponent } from '@iptvnator/ui/components';
import {
@@ -46,4 +46,52 @@ import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcu
export class WorkspaceShellComponent {
readonly facade = inject(WorkspaceShellFacade);
readonly keyboardShortcuts = inject(WorkspaceKeyboardShortcutsService);
private readonly header = viewChild<WorkspaceShellHeaderShortcutTarget>(
'workspaceHeader'
);
@HostListener('document:keydown', ['$event'])
onDocumentKeydown(event: KeyboardEvent): void {
if (
event.defaultPrevented ||
!this.facade.isElectron ||
!isFindShortcut(event)
) {
return;
}
const header = this.header();
const target = event.target;
if (isEditableTarget(target) && !header?.containsSearchInput(target)) {
return;
}
event.preventDefault();
this.facade.openGlobalSearch(this.facade.searchQuery());
setTimeout(() => header?.focusSearchInput({ select: true }));
}
}
function isFindShortcut(event: KeyboardEvent): boolean {
return (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f';
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
const tagName = target.tagName.toLowerCase();
return (
tagName === 'input' || tagName === 'textarea' || tagName === 'select'
);
}
interface WorkspaceShellHeaderShortcutTarget {
containsSearchInput(target: EventTarget | null): boolean;
focusSearchInput(options?: { select?: boolean }): void;
}